diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fb8dcce8..62a3f09a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -59,6 +59,9 @@ repos: - "**/build/**" - "-ignore" - "**/vendor/**" + # Separately licensed Apache-2.0 project + - "-ignore" + - "labs/AgentStream/**" # Generated protobuf / gRPC stubs - "-ignore" - "**/*.pb.go" diff --git a/CHANGELOG.md b/CHANGELOG.md index c282d64e..a24c533b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,9 @@ Guidelines for editors: ### Added +- **AgentStream:** add an experimental streaming evaluation framework for + self-evolving LLM agents under `labs/AgentStream` (#72). + ### Changed ### Deprecated diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9ce381a3..ed234c3e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,10 @@ # Contributing to Sico -Thanks for your interest in contributing! This project is MIT-licensed: by -submitting a pull request you agree that your contribution will be distributed -under the same terms. +Thanks for your interest in contributing! Sico is MIT-licensed, except for +[`labs/AgentStream`](labs/AgentStream), which is separately licensed under the +Apache License 2.0. By submitting a pull request, you agree that your +contribution will be distributed under the license applicable to the files you +modify. ## Start here @@ -53,9 +55,12 @@ service-specific commands, and troubleshooting notes. ## License headers -Every new source file (Go, Python, TypeScript, JavaScript, proto, shell, YAML, -Dockerfile, ...) must carry the MIT header. The pre-commit hook adds it -automatically. Generated files are intentionally excluded; see the +Every new source file outside `labs/AgentStream` (Go, Python, TypeScript, +JavaScript, proto, shell, YAML, Dockerfile, ...) must carry the MIT header. The +pre-commit hook adds it automatically. Files under `labs/AgentStream` instead +use the Apache-2.0 SPDX header described in the +[AgentStream contribution guide](labs/AgentStream/exgentic/CONTRIBUTING.md). +Generated files are intentionally excluded; see the [Development guide](docs/development.md#license-headers) and [pre-commit configuration](.pre-commit-config.yaml) for the exact ignore list. diff --git a/README.md b/README.md index 17426c92..563b7510 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,8 @@ sico/ ├── proto/ # Protobuf definitions shared by all services ├── sandbox/ # Sandbox runtimes (Android emulator, ...) ├── examples/ # Runnable workflow examples (auth, LLM Hub, conversation, sandbox, ...) +├── labs/ +│ └── AgentStream/ # Experimental streaming evaluation for self-evolving LLM agents ├── deploy/ │ ├── docker/ # docker-compose stack │ └── kind/ # Kind + Helm setup @@ -252,6 +254,8 @@ Contributions of all kinds are welcome: bug reports, feature ideas, documentatio Sico is licensed under the [MIT License](LICENSE). +The contents of [AgentStream](labs/AgentStream) are licensed separately under the [Apache License 2.0](labs/AgentStream/LICENSE). + ## Trademarks This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft trademarks or logos is subject to and must follow [Microsoft’s Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks). Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. Any use of third-party trademarks or logos are subject to those third-party’s policies. diff --git a/labs/AgentStream/LICENSE b/labs/AgentStream/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/labs/AgentStream/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/labs/AgentStream/README.md b/labs/AgentStream/README.md index 58fb6d24..304f324d 100644 --- a/labs/AgentStream/README.md +++ b/labs/AgentStream/README.md @@ -24,9 +24,14 @@ dapenghu@microsoft.com

+

+ arXiv +

+ ## 🚀 News +* **[2026/08]** Code is released! * **[2026/07]** Code is under preparation. Stay tuned! ## 📖 Overview @@ -38,3 +43,101 @@ Over these scenarios, we combinatorially evaluate five representative self-evolv Our results show that self-evolution reliability varies across streaming scenarios, the benefit of self-evolution is gated by model capability and non-monotonic in model strength, and no single method dominates across models and scenarios. These findings offer concrete guidance for selecting self-evolving methods across models and streaming scenarios. Overall, we advocate that self-evolving agents should be evaluated under realistic task streams rather than isolated single-task settings. + +
+ Framework of AgentStream +
+ +## ⚡️ Getting Started + +AgentStream is built on a locally adapted snapshot of the +[Exgentic](https://github.com/Exgentic/exgentic) framework, bundled under +[`exgentic`](./exgentic). This copy adds the self-evolving agents and streaming +experiment runners used by AgentStream. AgentStream-specific changes to the +snapshot are maintained in Sico, and the bundled package is not published +independently from this repository. The five self-evolving agents live under +[`exgentic/src/exgentic/agents`](./exgentic/src/exgentic/agents), and the +benchmarks are orchestrated through `exgentic`'s installation and runner +infrastructure. + +### 1. Requirements + +- Python `>= 3.11` +- [`uv`](https://github.com/astral-sh/uv) +- Docker (optional) + +### 2. Install the local exgentic (agent side) + +Clone the repo and create an editable environment from the bundled `exgentic`: + +```bash +git clone https://github.com/microsoft/Sico.git +cd Sico/labs/AgentStream/exgentic + +# Install the local ./src/exgentic in editable mode into .venv/ +uv sync + +# Activate the environment +source .venv/bin/activate +``` + +Verify that the self-evolving agents are visible from the local install: + +```bash +uv run exgentic list agents +``` + +### 3. Install benchmarks (benchmark side) + +Each benchmark is installed into isolated **`venv`** environment: + +```bash +cd Sico/labs/AgentStream/exgentic + + +uv run exgentic install --benchmark tau2 +uv run exgentic install --benchmark bfcl +uv run exgentic install --benchmark hle +uv run exgentic install --benchmark appworld +uv run exgentic install --benchmark swebench +uv run exgentic install --benchmark browsecompplus +``` + +### 4. API credentials + +The runners call LLMs through [LiteLLM](https://docs.litellm.ai/). Set the credentials for your provider in the [`exgentic/scripts//run_experiment.sh`](./exgentic/scripts/ace/run_experiment.sh): + +```bash +export OPENAI_API_KEY="..." +export OPENAI_API_BASE="..." +``` + +### 5. Run the streaming experiments + +Each method has its own runner under [`exgentic/scripts/`](./exgentic/scripts). The shell script selects the streaming scenario via `MODE` (`isolated` | `sequential` | `interleaved`), the model, the seed, and the benchmark stream: + +```bash +cd Sico/labs/AgentStream/exgentic/scripts/ace + +bash run_experiment.sh +``` + + +## 🙏 Acknowledgement +This work is based on [Exgentic](https://github.com/Exgentic/exgentic). We sincerely thank the authors and contributors of these excellent open-source projects. + +## 📚 Citation +If you find our work helpful, please consider citing: + +```bibtex +@article{yan2026agentstream, + title={AgentStream: How Well Do Self-Evolving LLM Agents Perform Under Streaming Tasks?}, + author={Yan, Dong and Liang, Jian and Hu, Dapeng and He, Ran and Yuan, Nicholas Jing and Zhang, Qi and Tan, Tieniu}, + journal={arXiv preprint arXiv:2608.00155}, + year={2026} +} +``` + +## 📄 License + +The contents of this AgentStream directory are licensed separately under the [Apache License 2.0](./LICENSE). diff --git a/labs/AgentStream/exgentic/.dockerignore b/labs/AgentStream/exgentic/.dockerignore new file mode 100644 index 00000000..79975c4c --- /dev/null +++ b/labs/AgentStream/exgentic/.dockerignore @@ -0,0 +1,19 @@ +.venv/ +.git/ +outputs/ +__pycache__/ +*.pyc +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +.exgentic/ +tests/ + +# Large benchmark data/assets — not needed in the base Docker image. +# Benchmarks that need these should use setup_script or volumes instead. +src/exgentic/benchmarks/browsecompplus/assets/ +# Tau2: exclude large pre-computed results and figures but keep domain +# data files (tasks.json, policy.md, db.json) which the session needs. +src/exgentic/benchmarks/tau2/installation/tau2-bench/data/tau2/results/ +src/exgentic/benchmarks/tau2/installation/tau2-bench/figs/ +src/exgentic/benchmarks/tau2/installation/tau2-bench/tests/ diff --git a/labs/AgentStream/exgentic/.gitattributes b/labs/AgentStream/exgentic/.gitattributes new file mode 100644 index 00000000..6313b56c --- /dev/null +++ b/labs/AgentStream/exgentic/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/labs/AgentStream/exgentic/.gitignore b/labs/AgentStream/exgentic/.gitignore new file mode 100644 index 00000000..2609c5fc --- /dev/null +++ b/labs/AgentStream/exgentic/.gitignore @@ -0,0 +1,121 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +!src/exgentic/interfaces/lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +src/exgentic/_version.py + +# Exgentic local data (venvs, caches, installations) +.exgentic/ +.exgentic_installations/ + +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# AI/CLI agent metadata +.bob/ +.claude/ +.cursor/ +.aider* +.copilot/ +.continue/ + +# VS Code settings +.vscode/ +.history/ +*.code-workspace + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Jupyter Notebook +.ipynb_checkpoints +.jupyter/ +profile_default/ + +# PyCharm +.idea/ + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype +.pytype/ + +# Cython debug symbols +cython_debug/ +outputs/ +# OS files +.DS_Store +Thumbs.db +data/ +src/exgentic/benchmarks/appworld/data/ +src/exgentic/benchmarks/appworld/experiments/ +src/exgentic/benchmarks/tau2/installation/ + +# Benchmark caches +tau2_disk_cache/ + +old_outputs2/ +outputs +*outputs* +.litellm_cache +trace.jsonl + +t.* +node_modules +package-lock.json +package.json +src/exgentic/benchmarks/browsecompplus/assets +exgentic_session_cache/ +scripts/litellm/docker_vm_data/Ubuntu.qcow2.zip diff --git a/labs/AgentStream/exgentic/.pre-commit-config.yaml b/labs/AgentStream/exgentic/.pre-commit-config.yaml new file mode 100644 index 00000000..553d4703 --- /dev/null +++ b/labs/AgentStream/exgentic/.pre-commit-config.yaml @@ -0,0 +1,78 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +# These component-specific checks are intentionally run manually; see DEVELOPMENT.md. +# Paths are evaluated from the Sico repository root. +files: ^labs/AgentStream/exgentic/ +exclude: ^labs/AgentStream/exgentic/\.venv/ + +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.1.6 + hooks: + - id: ruff + # Keep the bundled snapshot unchanged while checking syntax and + # undefined names. The explicit config avoids inheriting Sico's root + # Ruff settings when this hook is run from the monorepo root. + args: + - --config + - labs/AgentStream/exgentic/pyproject.toml + - --select + - E9,F63,F7,F82 + + - repo: https://github.com/Yelp/detect-secrets + rev: v1.5.0 + hooks: + - id: detect-secrets + args: [--baseline, labs/AgentStream/exgentic/misc/utils/.secrets.baseline] + exclude: ^labs/AgentStream/exgentic/misc/utils/\.secrets\.baseline$ + + - repo: https://github.com/astral-sh/uv-pre-commit + rev: 0.7.12 + hooks: + - id: uv-lock + args: [--locked, --project, labs/AgentStream/exgentic] + files: ^labs/AgentStream/exgentic/(uv\.lock|pyproject\.toml|uv\.toml)$ + + - repo: https://github.com/codespell-project/codespell + rev: v2.2.6 + hooks: + - id: codespell + args: [--toml, labs/AgentStream/exgentic/pyproject.toml] + exclude: ^labs/AgentStream/exgentic/(tests/benchmarks/recordings/|uv\.lock$) + additional_dependencies: + - tomli + + - repo: local + hooks: + - id: enforce-spdx-header + name: Enforce SPDX Header + entry: python labs/AgentStream/exgentic/misc/utils/enforce_spdx_header.py + language: python + files: ^labs/AgentStream/exgentic/.*\.py$ + types: [python] + - id: enforce-relative-imports + name: Enforce Relative Imports + entry: python labs/AgentStream/exgentic/misc/utils/enforce_relative_imports.py + language: python + # Adjust the files pattern to match your needs + files: ^labs/AgentStream/exgentic/src/.*\.py$ + # Optional: Specify types or exclude files + types: [python] + - id: enforce-dependency-caps + name: Enforce Dependency Version Caps + entry: python labs/AgentStream/exgentic/misc/utils/enforce_dependency_caps.py + language: python + files: ^labs/AgentStream/exgentic/pyproject\.toml$ + pass_filenames: false + + - repo: local + hooks: + - id: enforce-library-imports + name: Enforce Library Imports + entry: python labs/AgentStream/exgentic/misc/utils/enforce_library_imports.py + language: python + # Adjust the files pattern to match your needs + exclude: ^labs/AgentStream/exgentic/(src/.*\.py|misc/utils/enforce_(library_imports|relative_imports)\.py)$ + # Optional: Specify types or exclude files + types: [python] diff --git a/labs/AgentStream/exgentic/CONTRIBUTING.md b/labs/AgentStream/exgentic/CONTRIBUTING.md new file mode 100644 index 00000000..d3939f57 --- /dev/null +++ b/labs/AgentStream/exgentic/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing to the bundled Exgentic component + +This directory contains a locally adapted snapshot of +[Exgentic](https://github.com/Exgentic/exgentic) used by AgentStream. Changes +specific to this bundled copy are contributed through the +[Sico repository](https://github.com/microsoft/Sico), not through the upstream +Exgentic repository. + +## Contribution process + +Follow Sico's root [contribution guide](../../../CONTRIBUTING.md) for the fork, +branch, commit, pull-request, review, code-of-conduct, and security-reporting +processes. For local setup and component-specific checks, see +[DEVELOPMENT.md](./DEVELOPMENT.md). + +If a change applies to the original Exgentic project rather than this +AgentStream-specific copy, contribute it to the +[upstream repository](https://github.com/Exgentic/exgentic) separately. + +## License + +The contents of `labs/AgentStream`, including this bundled component, are +licensed under the Apache License 2.0; see [LICENSE](./LICENSE) and the +[AgentStream license](../LICENSE). This is an exception to Sico's root MIT +license. + +New source files in this directory must include the following SPDX identifier +using the appropriate comment syntax: + +```text +SPDX-License-Identifier: Apache-2.0 +``` diff --git a/labs/AgentStream/exgentic/DEVELOPMENT.md b/labs/AgentStream/exgentic/DEVELOPMENT.md new file mode 100644 index 00000000..3ccebda9 --- /dev/null +++ b/labs/AgentStream/exgentic/DEVELOPMENT.md @@ -0,0 +1,130 @@ +# Development Guide + +This guide covers setting up exgentic for local development, editing, and debugging. + +## Setup + +```bash +git clone https://github.com/microsoft/Sico.git +cd Sico/labs/AgentStream/exgentic +uv sync --frozen +``` + +## Setup Benchmarks & Agents + +Benchmarks and agents declare their dependencies through two mechanisms: + +- **`requirements.txt`** — pip packages installed automatically via `uv pip install` +- **`setup.sh`** — shell script for non-pip setup (apt packages, git clones, data downloads) + +Both are auto-discovered next to the benchmark/agent module directory. The `exgentic install` command runs both: + +```bash +# Benchmarks +uv run exgentic install --benchmark tau2 +uv run exgentic install --benchmark appworld +uv run exgentic install --benchmark gsm8k +uv run exgentic install --benchmark hotpotqa +uv run exgentic install --benchmark swebench +uv run exgentic install --benchmark browsecompplus + +# Agents +uv run exgentic install --agent litellm_tool_calling +uv run exgentic install --agent smolagents +uv run exgentic install --agent openai +uv run exgentic install --agent claude +uv run exgentic install --agent codex +uv run exgentic install --agent gemini +``` + +> **Note:** `exgentic setup` still works but is deprecated. Use `install`/`uninstall` instead. + +### Isolated Runners (venv / docker) + +By default, benchmarks run with the `venv` runner, which creates an isolated `uv` virtual environment per benchmark under `.exgentic//venv/`. This means **no local setup is needed** — dependencies are installed automatically in the venv on first run. + +You can also use the `docker` runner for full container isolation: + +```bash +uv run exgentic evaluate --benchmark tau2 --agent tool_calling --subset retail --num-tasks 2 \ + --model gpt-4o \ + --set benchmark.runner=docker \ + --set benchmark.user_simulator_model="gpt-4o" +``` + +Both isolated runners follow the same pattern: + +1. Install `requirements.txt` and run `setup.sh` in the isolated environment +2. Start `exgentic serve --cls --kwargs ` inside the venv/container +3. Communicate over HTTP via the runner transport layer + +Setup scripts can check the `EXGENTIC_DOCKER_BUILD` environment variable to distinguish a Docker build from a local setup (e.g., to skip interactive prompts or large downloads that are handled differently in containers). + +## API Credentials + +```bash +export OPENAI_API_KEY=... +# or +export ANTHROPIC_API_KEY=... +``` + +Or create a `.env` file in the project root — Exgentic loads it automatically. + +## Running Evaluations + +```bash +uv run exgentic list benchmarks +uv run exgentic list agents + +uv run exgentic evaluate --benchmark tau2 --agent tool_calling --subset retail --num-tasks 2 \ + --model gpt-4o \ + --set benchmark.user_simulator_model="gpt-4o" +``` + +## Tests + +```bash +# Core tests (no Docker or external services required) +uv run pytest tests/ --ignore=tests/integrations --ignore=tests/adapters/runners + +# Runner/transport tests (includes Docker tests on matching Python version) +uv run pytest tests/adapters/runners -v -p no:faulthandler + +# API-level tests only +uv run pytest tests/api + +# Skip tests requiring external services +uv run pytest tests/ -k "not litellm and not mcp" +``` + +The test suite includes **replay tests** that re-run recorded benchmark sessions without any external dependencies. Recordings are stored under `tests/benchmarks/recordings/` and use `ReplayBenchmark` + `ReplayAgent` to verify the execution loop end-to-end. + +## Linting + +These checks are not part of Sico's root CI. Contributors modifying the bundled +Exgentic snapshot should run them manually from the Sico repository root. +Install [pre-commit](https://pre-commit.com/#install) for your platform, then run: + +```bash +pre-commit run --config labs/AgentStream/exgentic/.pre-commit-config.yaml --all-files --show-diff-on-failure +``` + +## OpenTelemetry Tracing + +```bash +uv sync --extra otel + +export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 +export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf +export EXGENTIC_OTEL_ENABLED=true +``` + +See [OpenTelemetry semantic conventions](./docs/observability/semantic-conventions.md) for details. + +## Packaging + +The bundled Exgentic component is installed from source for AgentStream and is +not published independently from Sico. Do not create Exgentic release tags or +publish this copy to PyPI from this repository. + +For benchmark adapter design guidance, see `docs/adding-benchmarks.md`. diff --git a/labs/AgentStream/exgentic/LICENSE b/labs/AgentStream/exgentic/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/labs/AgentStream/exgentic/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/labs/AgentStream/exgentic/README.md b/labs/AgentStream/exgentic/README.md new file mode 100644 index 00000000..ca235afd --- /dev/null +++ b/labs/AgentStream/exgentic/README.md @@ -0,0 +1,263 @@ +Exgentic Banner + +> [!NOTE] +> This directory contains a locally adapted snapshot of the upstream +> [Exgentic](https://github.com/Exgentic/exgentic) project used by +> [AgentStream](../README.md). AgentStream-specific changes to this copy are +> maintained in Sico, and it is not published as a separate Exgentic package +> from this repository. Report issues with this bundled copy in the +> [Sico issue tracker](https://github.com/microsoft/Sico/issues); report issues +> with the original project to the upstream repository. + +

+ Evaluate any agent on any benchmark in the simplest way possible +

+ +--- + +## What is Exgentic? + +Exgentic is a universal evaluation framework that enables standardized testing of AI agents across diverse benchmarks and domains. It provides a consistent interface for evaluating any agent on any benchmark, making it easy to compare performance, reproduce results, and ensure your agent works reliably across different tasks and environments. + +## Who is it for? + +1. **General Audience** - Visit [www.exgentic.ai](https://www.exgentic.ai) to explore the first general agent leaderboard comparing leading agents and frontier models across varied tasks. +2. **Agent Builders** - Evaluate your agents comprehensively across multiple domains and benchmarks. +3. **Researchers & Component Developers** - Test agentic components (memory, context compression, planning) across different agents and domains. +4. **Benchmark Builders** - Evaluate your benchmark across multiple agents to ensure meaningful differentiation. + +--- + +## Quick Start + +### Installation + +```bash +git clone https://github.com/microsoft/Sico.git +cd Sico/labs/AgentStream/exgentic +uv sync --frozen +source .venv/bin/activate +``` + +The commands below assume that this local environment is active. Alternatively, +prefix each command with `uv run`. + +### API Credentials + +```bash +export OPENAI_API_KEY=... +# or +export ANTHROPIC_API_KEY=... +``` + +### Run an Evaluation + +```bash +# List available benchmarks and agents +exgentic list benchmarks +exgentic list agents + +# Evaluate an agent on a benchmark +exgentic evaluate --benchmark tau2 --agent tool_calling --subset retail --num-tasks 2 \ + --model gpt-4o \ + --set benchmark.user_simulator_model="gpt-4o" +``` + +Benchmarks are automatically installed on first run — no manual installation needed. You can also install them explicitly: + +```bash +exgentic install --benchmark tau2 # install deps + data (default) +exgentic install --agent tool_calling +exgentic install --benchmark tau2 --docker # build Docker image +exgentic install --benchmark tau2 --local # install into local environment +exgentic uninstall --benchmark tau2 # remove installed environment +``` + +> **Note:** `exgentic setup` still works but is deprecated in favor of `install`/`uninstall`. + +For full container isolation, use the Docker runner (`--set benchmark.runner=docker`). You only need Docker installed and running: + +```bash +exgentic evaluate --benchmark tau2 --agent tool_calling --subset retail --num-tasks 2 \ + --model gpt-4o \ + --set benchmark.runner=docker \ + --set benchmark.user_simulator_model="gpt-4o" +``` + +### Python API + +After syncing the bundled environment above, use exgentic as a library: + +```python +from exgentic import evaluate + +results = evaluate( + benchmark="tau2", + agent="tool_calling", + subset="retail", + num_tasks=2, + model="gpt-4o", + benchmark_kwargs={"user_simulator_model": "gpt-4o"}, +) +``` + +For more examples, see the [`examples/`](./examples/) directory. + +--- + +## Available Benchmarks + +```bash +exgentic list benchmarks +``` + +| Benchmark | Description | +|-----------|-------------| +| **tau2** | Simulated customer support tasks across multiple domains (mock, retail, airline, telecom) | +| **appworld** | Multi-app API environment testing agents' ability to interact with application interfaces | +| **browsecompplus** | Web search and browsing benchmark for information retrieval and navigation | +| **swebench** | Software engineering benchmark for resolving real-world GitHub issues | +| **hotpotqa** | Multi-hop question answering over Wikipedia | +| **gsm8k** | Grade school math word problems with optional calculator tool | +| **bfcl** | Berkeley Function Calling Leaderboard for evaluating tool-use capabilities | + +## Available Agents + +| Agent | Description | +|-------|-------------| +| **LiteLLM Tool Calling** | Generic tool-calling agent via LiteLLM | +| **SmolAgents** | HuggingFace SmolAgents framework | +| **OpenAI MCP** | OpenAI Responses API with MCP tools | +| **Claude Code** | Anthropic Claude Code agent | +| **Codex CLI** | OpenAI Codex CLI agent | +| **Gemini CLI** | Google Gemini CLI agent | + +--- + +## Dashboard + +Dashboard + +```bash +exgentic dashboard +``` + +--- + +## Output Structure + +Each run creates its own directory under `outputs//`: + +```text +outputs// +├── results.json # Overall scores, costs, per-session statistics +├── benchmark_results.json # Benchmark-specific aggregated results +├── run/ +│ ├── config.json # Snapshot of benchmark and agent configuration +│ ├── run.log # Main execution log +│ └── warnings.log # Warnings during execution +└── sessions// + ├── config.json # Session configuration + ├── results.json # Session results + ├── trajectory.jsonl # One JSON line per step (action + observation) + ├── agent/ + │ └── agent.log # Agent execution log + └── benchmark/ + ├── results.json # Benchmark-specific results + └── session.log # Benchmark session log +``` + +--- + +## CLI Reference + +CLI + +```bash +# Discover +exgentic list benchmarks +exgentic list subsets --benchmark tau2 +exgentic list tasks --benchmark tau2 --subset retail --limit 5 +exgentic list agents +exgentic install --benchmark tau2 +exgentic install --benchmark tau2 --docker +exgentic install --benchmark tau2 --local +exgentic uninstall --benchmark tau2 + +# Run +exgentic evaluate --benchmark tau2 --agent tool_calling --subset airline --num-tasks 10 +exgentic batch run --benchmark tau2 --agent tool_calling --subset airline --num-tasks 10 + +# Inspect +exgentic status --benchmark tau2 --agent tool_calling --subset airline --num-tasks 10 +exgentic preview --benchmark tau2 --agent tool_calling --subset airline --num-tasks 10 +exgentic results --benchmark tau2 --agent tool_calling --subset airline --num-tasks 10 + +# Analyze +exgentic compare --agents tool_calling openai --benchmark tau2 + +# Explore +exgentic dashboard +``` + +--- + +## Advanced + +### Model Configuration + +```bash +exgentic evaluate --benchmark tau2 --agent tool_calling --subset retail --num-tasks 2 \ + --set agent.model.temperature=0.2 +``` + +Supported fields: `temperature`, `top_p`, `max_tokens`, `reasoning_effort`, `num_retries`, `retry_after`, `retry_strategy` + +### Run Limits + +```bash +exgentic evaluate --benchmark tau2 --agent tool_calling --subset retail --num-tasks 2 \ + --max-steps 100 --max-actions 100 +``` + +Sessions stop at either limit and record `limit_reached` status. Default: 100 for both. + +### HuggingFace + +Use HuggingFace models or run evaluations on HuggingFace Jobs. See [docs/huggingface.md](./docs/huggingface.md). + +--- + +## How It Works + +To learn more about Exgentic's architecture and design, see our [arXiv paper](https://arxiv.org/abs/2602.22953). + +## Development + +For local development, editing, and contributing, see [DEVELOPMENT.md](./DEVELOPMENT.md). + +## Contributing + +We welcome issues and pull requests! See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines. + +## Citing Exgentic + +```bibtex +@misc{bandel2026generalagentevaluation, + title={General Agent Evaluation}, + author={Elron Bandel and Asaf Yehudai and Lilach Eden and Yehoshua Sagron and Yotam Perlitz and Elad Venezian and Natalia Razinkov and Natan Ergas and Shlomit Shachor Ifergan and Segev Shlomov and Michal Jacovi and Leshem Choshen and Liat Ein-Dor and Yoav Katz and Michal Shmueli-Scheuer}, + year={2026}, + url={https://arxiv.org/abs/2602.22953}, +} +``` + +## License + +Apache License 2.0 — see [LICENSE](LICENSE). + +## Support + +For questions about this bundled copy, +[open an issue](https://github.com/microsoft/Sico/issues) in Sico. For the +original Exgentic project, use the +[upstream issue tracker](https://github.com/Exgentic/exgentic/issues). diff --git a/labs/AgentStream/exgentic/docs/README.md b/labs/AgentStream/exgentic/docs/README.md new file mode 100644 index 00000000..c917d4f9 --- /dev/null +++ b/labs/AgentStream/exgentic/docs/README.md @@ -0,0 +1,38 @@ +# Exgentic Documentation + +Welcome to the Exgentic docs. Use the table below to find what you need. + +--- + +## Using Exgentic + +| Document | Description | +|----------|-------------| +| [CLI Reference](./cli-reference.md) | Every command, flag, and environment variable | +| [Python API](./python-api.md) | `evaluate()`, `execute()`, `aggregate()`, `status()`, `list_*()`, and all other library functions | +| [Custom Models](./custom-models.md) | Use any LLM provider (OpenAI, Anthropic, Azure, Bedrock, Ollama, and more) via LiteLLM | +| [Batch Runs](./batch.md) | Run parameter sweeps, manage large evaluations, export to CSV, publish to HuggingFace | +| [Runners](./runners.md) | `direct`, `venv`, `docker` — isolation levels, configuration, Docker-in-Docker | +| [Output Format](./output-format.md) | Schema for `results.json`, `trajectory.jsonl`, session results, and cost reports | +| [Observers](./observers.md) | Hook into the evaluation lifecycle for custom logging, monitoring, and early stopping | + +## Extending Exgentic + +| Document | Description | +|----------|-------------| +| [Adding Agents](./adding-agents.md) | Write a new agent adapter — design principles, required methods, file layout, and validation checklist | +| [Adding Benchmarks](./adding-benchmarks.md) | Write a new benchmark adapter — design principles, contract rules, and validation checklist | +| [Replay Testing](./replay-testing.md) | Test benchmark and agent adapters end-to-end without API calls, using recorded sessions | + +## Observability + +| Document | Description | +|----------|-------------| +| [Quick Start](./observability/quickstart.md) | Set up OpenTelemetry tracing with Jaeger in five minutes | +| [Semantic Conventions](./observability/semantic-conventions.md) | Full reference of every span and attribute Exgentic emits | + +## Other resources + +- [README.md](../README.md) — project overview, quick start, CLI reference, and available benchmarks/agents +- [DEVELOPMENT.md](../DEVELOPMENT.md) — local setup, running tests, and linting +- [CONTRIBUTING.md](../CONTRIBUTING.md) — contribution workflow, legal requirements, and PR guidelines diff --git a/labs/AgentStream/exgentic/docs/adding-agents.md b/labs/AgentStream/exgentic/docs/adding-agents.md new file mode 100644 index 00000000..2f6b3f2f --- /dev/null +++ b/labs/AgentStream/exgentic/docs/adding-agents.md @@ -0,0 +1,384 @@ +# Adding Agents + +This document defines the agent design principles for Exgentic. + +It is intentionally opinionated. An agent adapter should not just "work"; it should cleanly separate configuration from execution, isolate heavy dependencies, and adapt to any benchmark contract without requiring the benchmark to change. + +Use these existing adapters as reference points: +- `src/exgentic/agents/litellm_tool_calling/litellm_tool_calling_agent.py` + `instance.py` (split pattern) +- `src/exgentic/agents/cli/claude/agent.py` (light pattern, single file) + +**Related docs:** +[docs/](./README.md) · [Adding Benchmarks](./adding-benchmarks.md) · [Custom Models](./custom-models.md) · [Runners](./runners.md) · [Replay Testing](./replay-testing.md) · [CONTRIBUTING.md](../CONTRIBUTING.md) + +## Core Principle + +The agent adapts to the benchmark contract, not the other way around. + +That means: +- the benchmark decides the task, context, actions, step flow, and scoring +- the agent receives these through `_get_instance_kwargs()` and must work within them +- the agent should not require benchmark modifications to function +- the agent should not impose protocol-specific assumptions on the benchmark + +The default goal should be the thinnest possible agent wrapper. + +That means: +- translate the benchmark's actions into whatever protocol your agent uses (tool calls, code generation, CLI commands) +- do not reshape the benchmark contract to match your model's preferred format +- keep the configuration surface small and explicit + +## Architecture + +Exgentic agents are split into two classes with distinct roles: + +### Agent (config, host-side) + +`Agent` is a lightweight Pydantic model that holds configuration. It lives on the host and is never sent into an isolated runner. It has no heavy dependencies. + +Responsibilities: +- declare `display_name` and `slug_name` as `ClassVar[str]` +- hold user-facing configuration fields (model name, max steps, feature flags) +- implement `_get_instance_class()` to resolve the execution class +- implement `_get_instance_kwargs()` to translate config + benchmark contract into constructor arguments +- optionally override `setup()` for non-pip setup (Docker builds, npm installs) +- optionally override `model_name` / `get_models_names()` for dashboard metadata + +### AgentInstance (execution, venv-side) + +`AgentInstance` is the execution class. It runs inside the runner (venv, Docker, or local) and may import heavy third-party libraries. + +Responsibilities: +- implement `react(observation) -> Action | None` as the core decision loop +- implement `close()` for resource cleanup +- optionally override `start()` for initialization that happens after construction +- optionally override `get_cost()` to report monetary cost + +The agent instance receives a single `session_id` in its constructor, which scopes all logs and artifacts. Additional kwargs come from `_get_instance_kwargs()`. + +## Key Pattern: Lazy Import for Dependency Isolation + +The `_get_instance_class()` classmethod must use a lazy import so that heavy dependencies are only loaded inside the runner environment, not on the host. + +```python +@classmethod +def _get_instance_class(cls): + from .instance import MyAgentInstance + + return MyAgentInstance +``` + +This is the same pattern that `Benchmark._get_session_class()` uses. It ensures the host process never imports libraries like `litellm`, `smolagents`, `openai`, or any other agent-specific SDK. + +## When to Split Files + +**Split into separate files** when your agent depends on heavy third-party libraries: + +``` +src/exgentic/agents/my_agent/ + __init__.py + my_agent.py # Agent subclass (light, no heavy imports) + instance.py # AgentInstance subclass (imports litellm, openai, etc.) + requirements.txt # Agent-specific pip dependencies + setup.sh # Optional non-pip setup + utils.py # Optional helpers +``` + +Examples: `litellm_tool_calling`, `smolagents`, `openai` + +**Keep everything in one file** when dependencies are light or already available in the base environment: + +``` +src/exgentic/agents/my_agent/ + __init__.py + agent.py # Both Agent and AgentInstance in one file +``` + +Example: `cli/claude` (the instance class is in the same file because it only depends on stdlib and core Exgentic types) + +The rule is simple: if importing the instance class would pull in packages that are not in the base `exgentic` install, split the files. + +## Required Methods + +### On the Agent class + +#### `_get_instance_class()` (classmethod, abstract) + +Returns the `AgentInstance` subclass. Must use a lazy import. + +```python +@classmethod +def _get_instance_class(cls): + from .instance import MyAgentInstance + + return MyAgentInstance +``` + +#### `_get_instance_kwargs()` (abstract) + +Translates the agent's configuration into constructor kwargs for the instance class. Task, context, and actions are passed separately via `start()`, not through the constructor. + +```python +def _get_instance_kwargs(self, session_id: str) -> dict[str, Any]: + return { + "session_id": session_id, + "model": self.model, + "max_steps": self.max_steps, + } +``` + +The returned dict is passed directly to the instance class constructor. Every key must match a constructor parameter. + +### On the AgentInstance class + +#### `react(observation) -> Action | None` (abstract) + +The core decision loop. Receives an `Observation` (or `None` on the first call) and returns an `Action` to take, or `None` to signal that the agent is done. + +```python +def react(self, observation: Observation | None) -> Action | None: + # Process observation, decide next action + # Return None when the agent decides it is finished + ... +``` + +#### `close()` (abstract) + +Cleanup resources. Called when the session ends, whether or not the agent finished normally. + +```python +def close(self) -> None: + # Release connections, flush logs, etc. + pass +``` + +#### `start(task, context, actions)` (optional override) + +Called after construction but before the first `react()`. Receives the benchmark's task string, context dict, and list of action types. The base implementation stores these as `self.task`, `self.context`, and `self.actions`. Override to perform initialization that depends on these values (e.g., seeding a conversation with the task prompt). + +#### `get_cost()` (optional) + +Returns a `CostReport` with estimated monetary cost. Default returns an empty report. Override to track API costs. + +```python +def get_cost(self) -> CostReport: + return self._cost_data +``` + +## Registration + +Every agent must be registered in `src/exgentic/interfaces/registry.py` in the `AGENTS` dict. + +```python +AGENTS: dict[str, RegistryEntry] = { + # ...existing entries... + "my_agent": RegistryEntry( + slug_name="my_agent", + display_name="My Agent", + module="exgentic.agents.my_agent.my_agent", + attr="MyAgent", + kind="agent", + ), +} +``` + +Requirements: +- `slug_name` must match the `slug_name` ClassVar on the Agent class exactly +- `display_name` must match the `display_name` ClassVar on the Agent class exactly +- `module` is the dotted Python module path to the file containing the Agent class +- `attr` is the class name within that module +- `kind` must be `"agent"` + +The registry validates these constraints at load time. Mismatches will raise at startup. + +## Setup + +### `requirements.txt` + +List agent-specific pip dependencies. The runner installs these automatically into the isolated environment. + +``` +litellm>=1.50.0 +``` + +Place the file in the agent's package directory. The `RunnerMixin` auto-discovers it by walking up from the module file. + +### `setup.sh` + +Optional script for non-pip setup. Runs after dependencies are installed. + +```bash +#!/usr/bin/env bash +set -euo pipefail +# Build Docker images, install npm packages, download models, etc. +``` + +Place it next to the agent module. The `RunnerMixin` auto-discovers it. + +Both files are automatically found by the framework through `RunnerMixin.requirements_txt` and `RunnerMixin.setup_script`. No manual wiring is needed. + +## Recommended File Structure + +### Split pattern (heavy deps) + +``` +src/exgentic/agents/my_agent/ + __init__.py + my_agent.py # Agent subclass + instance.py # AgentInstance subclass + requirements.txt # e.g., litellm>=1.50.0 + setup.sh # optional + utils.py # optional helpers +``` + +**my_agent.py** (host-side, no heavy imports): + +```python +from __future__ import annotations + +from typing import Any, ClassVar + +from ...core.agent import Agent +from ...core.types import ActionType, ModelSettings + + +class MyAgent(Agent): + display_name: ClassVar[str] = "My Agent" + slug_name: ClassVar[str] = "my_agent" + + model: str = "gpt-4o" + max_steps: int = 100 + + @classmethod + def _get_instance_class(cls): + from .instance import MyAgentInstance + + return MyAgentInstance + + @property + def model_name(self) -> str: + return self.model + + def _get_instance_kwargs(self, session_id: str) -> dict[str, Any]: + return { + "session_id": session_id, + "model": self.model, + "max_steps": self.max_steps, + } +``` + +**instance.py** (runner-side, may import heavy libs): + +```python +from __future__ import annotations + +from typing import Any, Optional + +import some_heavy_library # only loaded inside the runner + +from ...core.agent_instance import AgentInstance +from ...core.types import Action, ActionType, Observation + + +class MyAgentInstance(AgentInstance): + def __init__( + self, + session_id: str, + task: str, + context: dict[str, Any], + actions: list[ActionType], + model: str, + max_steps: int, + ): + super().__init__(session_id) + self.task = task + self.context = context + self.actions = actions + self.model = model + self.max_steps = max_steps + self._step_count = 0 + + def react(self, observation: Optional[Observation]) -> Optional[Action]: + self._step_count += 1 + if self._step_count > self.max_steps: + return None + # Agent decision logic here + ... + + def close(self) -> None: + pass +``` + +### Light pattern (no heavy deps) + +``` +src/exgentic/agents/my_agent/ + __init__.py + agent.py # Both Agent and AgentInstance +``` + +Keep both classes in a single file when the instance has no third-party imports beyond what exgentic already provides. + +## Validation Checklist + +Before opening a PR for a new agent, validate all of the following. + +### Contract validation + +- Agent class declares `display_name` and `slug_name` as `ClassVar[str]` +- `_get_instance_class()` uses a lazy import +- `_get_instance_kwargs()` returns a dict whose keys match the instance constructor +- The instance implements `react()` and `close()` +- The instance calls `super().__init__(session_id)` in its constructor + +### Dependency isolation validation + +- The Agent file does not import heavy third-party libraries at module level +- Heavy imports only appear inside `_get_instance_class()` or in the instance module +- `requirements.txt` lists all agent-specific dependencies + +### Registry validation + +- `slug_name` in the registry entry matches the class `slug_name` exactly +- `display_name` in the registry entry matches the class `display_name` exactly +- `module` path resolves to the correct file +- `attr` matches the Agent class name +- `kind` is `"agent"` + +### Functional validation + +- Agent is discoverable through the registry (`load_agent("my_agent")` succeeds) +- Agent works with at least one benchmark end to end +- `react()` correctly returns `None` when the agent decides it is done +- `close()` does not raise +- `get_cost()` returns a valid `CostReport` + +### Quality validation + +- `py_compile` passes for all changed Python files +- `pre-commit` passes for changed files +- `git diff --check` passes + +## Practical Rule of Thumb + +When in doubt, ask: + +1. Does the agent adapt to the benchmark, or does it require the benchmark to change? +2. Are heavy dependencies isolated behind a lazy import? +3. Is the Agent file importable without installing agent-specific packages? +4. Does `_get_instance_kwargs()` faithfully pass the benchmark contract through? +5. Will this agent work with benchmarks that have very different action spaces? +6. Is the configuration surface minimal and explicit? + +If the answer to any of those is no, the adapter is probably too coupled or too leaky. + +--- + +## See also + +- [Adding Benchmarks](./adding-benchmarks.md) — the other side of the contract +- [Custom Models](./custom-models.md) — configuring LLM providers and sampling parameters for the `tool_calling` agent +- [Runners](./runners.md) — how setup.sh and requirements.txt are discovered and executed +- [Replay Testing](./replay-testing.md) — write end-to-end tests for your agent without API calls +- [CONTRIBUTING.md](../CONTRIBUTING.md) — PR workflow and legal requirements +- [docs/](./README.md) — documentation index diff --git a/labs/AgentStream/exgentic/docs/adding-benchmarks.md b/labs/AgentStream/exgentic/docs/adding-benchmarks.md new file mode 100644 index 00000000..4fd56287 --- /dev/null +++ b/labs/AgentStream/exgentic/docs/adding-benchmarks.md @@ -0,0 +1,364 @@ +# Adding Benchmarks + +This document defines the benchmark design principles for Exgentic. + +It is intentionally opinionated. A benchmark adapter should not just "work"; it should preserve the benchmark's meaning while still fitting Exgentic's agent abstraction cleanly. + +Use these existing adapters as reference points: +- `src/exgentic/benchmarks/tau2/tau2_benchmark.py` +- `src/exgentic/benchmarks/bfcl/bfcl_benchmark.py` + +**Related docs:** +[docs/](./README.md) · [Adding Agents](./adding-agents.md) · [Runners](./runners.md) · [Replay Testing](./replay-testing.md) · [Output Format](./output-format.md) · [CONTRIBUTING.md](../CONTRIBUTING.md) + +## Core Principle + +The benchmark owns the contract. + +That means the benchmark decides: +- what the task is +- what context the agent receives +- what actions exist +- how steps progress +- when a session is finished +- how scoring works + +The agent should adapt to the benchmark contract through Exgentic's normal interfaces. The benchmark should not be shaped around one specific model protocol. + +The default goal should be the thinnest possible benchmark wrapper. + +That means: +- reuse the source benchmark wherever possible +- add only the translation layers that are actually necessary +- avoid reimplementing benchmark logic unless there is a clear reason +- avoid introducing runtime behavior that exists only to satisfy one agent or one model protocol + +The target is simple: +- make the benchmark accessible to any Exgentic agent +- while adding the minimum adapter surface necessary +- and without clashing with agent-specific assumptions + +## Principles + +### 1. Keep the agent-facing contract protocol-agnostic + +Do not define a benchmark in terms of OpenAI tool calls, raw assistant messages, or any other provider-specific response format. + +Define it in terms of: +- task semantics +- available actions +- observations +- finish conditions +- score + +Protocol-specific translation belongs in adapters, not in the benchmark contract. + +Bad: +- "The model must return all tool calls in one assistant message." + +Good: +- "The task is complete when the required actions have been taken and the benchmark-specific finish condition is met." + +### 2. The task should be the real task + +`task` should contain the actual task the agent is meant to solve. + +Do not wrap the task in fake chat scaffolding unless that scaffolding is genuinely part of the benchmark. + +If the benchmark is not about user interaction, do not invent a chat conversation just to make it look conversational. + +Bad: +- synthetic "user" messages when the benchmark is not actually testing user interaction +- generic wrapper prompts replacing the real benchmark task + +Good: +- the benchmark prompt itself is the `task` + +### 3. Context should contain only what the agent should know + +`context` is not a metadata dump. + +It should contain only information that is necessary for the agent to behave correctly on the task. + +Keep internal benchmark metadata out of `context`, including: +- subset names +- source dataset ids +- registry information +- adapter implementation details + +Good context: +- policy text +- execution constraints +- information the agent genuinely needs to act correctly + +Bad context: +- `"subset": "live_parallel_multiple"` +- `"benchmark": "bfcl"` + +### 4. Actions should represent semantic operations + +Actions are the benchmark's action space. + +Name and describe them in terms of what they do, not in terms of a transport protocol. + +Prefer "actions" over protocol-specific terms like "tool calls" in benchmark-facing language, because not all agents consume or produce actions through the same protocol. + +If the source benchmark exposes functions, commands, or tools, translate those into Exgentic actions at the boundary. + +### 5. Use `finish` only as part of the benchmark contract + +`finish` is valid when the benchmark needs an explicit end-of-step or end-of-task signal. + +It should exist because the benchmark contract needs it, not because a specific model API needs it. + +Use it when: +- the benchmark has multiple steps or turns and needs an explicit transition point +- the benchmark needs a clear "done with this step" signal +- the benchmark should allow completion without another normal action + +Do not force `finish` into a benchmark if the source benchmark's semantics are cleaner without it. + +### 6. Distinguish execution modes by contract, not by protocol + +If a benchmark has single-turn, live, or multi-turn variants, define those as execution contracts. + +The important differences are things like: +- whether more steps may follow +- whether the task ends after the current finish +- whether action outputs affect later state +- whether the benchmark continues after a step completes + +Do not define the mode in terms of how many assistant messages or tool-call payloads a model is allowed to emit. + +Important: +- single-turn does not necessarily mean a single action +- multi-action single-turn tasks are valid +- the distinction is about step structure, not about one specific model protocol + +### 7. Action outputs must be honest + +If the benchmark can produce real execution outputs, use them. + +If it cannot, do not fabricate realistic outputs that imply more runtime semantics than actually exist. + +Be explicit in the contract when actions are only being recorded rather than executed. + +Good: +- real execution results when the source benchmark exposes an official executor +- `Action recorded.` when there is no real runtime execution for that task family + +Bad: +- made-up outputs that look like real environment state changes when none were actually computed + +### 8. Reuse external harnesses as the source of truth where possible + +When adapting an external benchmark, prefer to reuse: +- dataset loading +- official assets +- ground-truth files +- official checkers or scorers +- official execution helpers + +Avoid copying large chunks of benchmark logic into Exgentic if the source repository already provides them. + +But there is an important boundary: +- external harnesses should be the source of truth for benchmark assets and scoring +- they should not automatically own the Exgentic runtime contract + +If the external harness assumes a model-specific interaction pattern, Exgentic should usually keep its own runtime and bridge to the harness at load/score time instead. + +When choosing between two valid integrations, prefer the thinner one. + +Use the more complex approach only when the thinner one would: +- distort benchmark meaning +- hard-code one agent's assumptions +- or force Exgentic to own logic that should stay with the source benchmark + +### 9. Be explicit about what is official and what is adapted + +If the adapter preserves official scoring but changes runtime behavior, document that clearly. + +If some subsets use official execution while others only use official scoring, document that too. + +Do not imply full equivalence when the integration is intentionally more abstract than the source benchmark. + +For each benchmark adapter, it should be easy to answer: +- What comes directly from the source benchmark? +- What is adapted by Exgentic? +- What is exact? +- What is approximate? + +### 10. Success, failure, and error must stay distinct + +Finished benchmark failures are not the same as runtime errors. + +The adapter should keep these states separate: +- success: benchmark completed and passed +- unsuccessful: benchmark completed and failed +- unfinished: benchmark did not complete +- error: adapter or runtime failure prevented a proper benchmark result + +Do not swallow real errors and report them as ordinary failures. + +If an exception happens, record it explicitly in session metadata. + +### 11. The benchmark should work for many agents, not just one + +A benchmark adapter should not depend on modifying one particular agent implementation. + +Prefer to build benchmark logic around Exgentic's shared abstractions: +- `task` +- `context` +- `actions` +- observations +- `Session.start()` +- `Session.step()` +- `Session.done()` +- `Session.score()` + +If the adapter only works because one agent has special behavior, the adapter is too coupled. + +### 12. Keep setup, runtime, and registration separate + +A well-structured benchmark adapter usually has three separate concerns: + +1. Setup +- external checkout or installation +- pinned dependencies +- benchmark-specific environment preparation + +2. Runtime +- session logic +- task loading +- action translation +- scoring + +3. Registration +- registry entry +- subset listing +- CLI discoverability + +Do not mix setup logic directly into the runtime path when it can be handled once in `setup.sh`. + +### 13. The main benchmark file must not import external dependencies + +The main benchmark file (`_benchmark.py`) defines the `Benchmark` subclass that Exgentic loads in the host process. This file **must be importable without any benchmark-specific dependencies installed**. + +External dependencies (benchmark harnesses, datasets, ML libraries, etc.) belong in **separate files** that are only loaded inside the runner subprocess through `_get_evaluator_class()` and `_get_session_class()`. + +**Rule:** The benchmark class file may only import from: +- Python standard library +- `pydantic` +- `exgentic` core modules + +All other imports must live in evaluator/session files that are accessed through the class getters. + +**Why:** Exgentic loads the benchmark class in the host process to read configuration (runner type, evaluator/session class names, kwargs). The actual benchmark execution happens inside an isolated runner (venv or Docker). If the main file imports heavy dependencies, the host process fails when those deps are only installed inside the runner environment. + +Bad: +```python +# _benchmark.py +from some_harness import HarnessRunner # ← breaks host import + +class MyBenchmark(Benchmark): + ... +``` + +Good: +```python +# _benchmark.py — no external deps +class MyBenchmark(Benchmark): + def _get_evaluator_class(self): + from ._eval import MyEvaluator # loaded inside runner + return MyEvaluator + +# _eval.py — external deps are fine here +from some_harness import HarnessRunner # ← only loaded in runner subprocess +``` + +## Required Structure + +For a benchmark package under `src/exgentic/benchmarks//`: + +- `_benchmark.py` **(required)** + - `Benchmark` subclass only + - no external dependency imports + - `_get_evaluator_class()` and `_get_session_class()` return classes from other files +- `_eval.py` or `_session.py` **(required if benchmark has external deps)** + - evaluator, session, and runtime logic + - may import external dependencies at module level + - only loaded inside the runner subprocess +- `setup.sh` + - benchmark installation/bootstrap +- optional shim module + - thin import boundary around an external harness +- optional helper modules + - action translation, scoring helpers, data parsing + +Then register it in: +- `src/exgentic/interfaces/registry.py` + +## Validation Checklist + +Before opening a PR for a new benchmark, validate all of the following. + +### Contract validation + +- `task` is the actual task, not fake wrapper chat +- `context` contains only agent-relevant information +- actions are semantically named +- `finish` exists only if the benchmark contract needs it +- success/failure/error semantics are distinct + +### Import validation + +- the main benchmark file (`_benchmark.py`) imports **no external dependencies** +- `_get_evaluator_class()` and `_get_session_class()` load from separate files +- `python -c "from exgentic.benchmarks.._benchmark import "` works without deps installed + +### Functional validation + +- benchmark is discoverable through the registry +- subsets list correctly +- tasks list correctly +- setup script works from a clean environment +- at least one happy-path task works end to end +- benchmark works with the default venv runner (not just direct) +- at least one failure-path task is represented correctly +- adapter errors surface as errors, not silent failures + +### Source-of-truth validation + +- official assets are reused where possible +- official scoring is reused where possible +- any remaining deviations from the source benchmark are documented explicitly + +### Quality validation + +- `py_compile` passes for changed Python files +- `pre-commit` passes for changed files +- `git diff --check` passes + +## Practical Rule Of Thumb + +When in doubt, ask: + +1. Is this benchmark contract describing the task, or just mirroring one model API? +2. Is this information something the agent should truly know? +3. Is this the thinnest adapter that still preserves the benchmark's meaning? +4. Am I reusing the source benchmark where it helps, without letting it dictate the wrong runtime shape? +5. Are the benchmark outputs honest about what was actually executed? +6. Will this adapter still make sense for a very different kind of Exgentic agent? + +If the answer to any of those is no, the adapter is probably too coupled or too misleading. + +--- + +## See also + +- [Adding Agents](./adding-agents.md) — the other side of the contract +- [Runners](./runners.md) — how setup.sh and requirements.txt are discovered and executed +- [Replay Testing](./replay-testing.md) — write end-to-end tests for your benchmark without API calls +- [Output Format](./output-format.md) — trajectory.jsonl and results.json schemas +- [CONTRIBUTING.md](../CONTRIBUTING.md) — PR workflow and legal requirements +- [docs/](./README.md) — documentation index diff --git a/labs/AgentStream/exgentic/docs/batch.md b/labs/AgentStream/exgentic/docs/batch.md new file mode 100644 index 00000000..86e19700 --- /dev/null +++ b/labs/AgentStream/exgentic/docs/batch.md @@ -0,0 +1,269 @@ +# Batch Runs + +The `batch` commands let you manage large evaluations across multiple configurations — parameter sweeps, multi-benchmark comparisons, re-runs of failed sessions — without writing orchestration scripts. + +**Related docs:** +[docs/](./README.md) · [CLI Reference](./cli-reference.md) · [Python API](./python-api.md) · [Output Format](./output-format.md) + +--- + +## When to use batch vs evaluate + +| Scenario | Command | +|----------|---------| +| Single benchmark run | `exgentic evaluate` | +| Multiple models on one benchmark | `batch evaluate` with config files | +| Re-run only failed sessions | `batch evaluate` (skips completed by default) | +| Sweep temperature/model grids | `batch evaluate` + `batch patch` | +| Publish results to HuggingFace | `batch publish` | +| Export results to CSV | `batch extract` | + +--- + +## Config files + +Every batch command operates on **config files** — JSON files that describe a run. There are two kinds. + +### RunConfig + +Describes a full multi-task run. + +```json +{ + "benchmark": "tau2", + "agent": "tool_calling", + "subset": "retail", + "num_tasks": 10, + "model": "gpt-4o", + "benchmark_kwargs": { + "user_simulator_model": "gpt-4o" + }, + "agent_kwargs": { + "model_settings": { + "temperature": 0.2 + } + }, + "output_dir": "./outputs", + "max_steps": 100, + "max_actions": 100 +} +``` + +### SessionConfig + +Describes a single task. Used when you need per-task control or when replaying individual sessions. + +```json +{ + "benchmark": "tau2", + "agent": "tool_calling", + "task_id": "retail_1", + "subset": "retail", + "model": "gpt-4o", + "output_dir": "./outputs" +} +``` + +### Full field reference + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `benchmark` | string | required | Benchmark slug (e.g. `tau2`) | +| `agent` | string | required | Agent slug (e.g. `tool_calling`) | +| `subset` | string | null | Benchmark subset | +| `task_ids` | list[string] | null | Explicit task IDs to run | +| `num_tasks` | int | null | Number of tasks (randomly sampled if task_ids not set) | +| `task_id` | string | required (SessionConfig) | Single task ID | +| `model` | string | null | Model override | +| `output_dir` | string | `./outputs` | Where to write results | +| `cache_dir` | string | null | Cache directory | +| `run_id` | string | auto | Deterministic ID derived from config | +| `max_steps` | int | 100 | Steps per session | +| `max_actions` | int | 100 | Actions per session | +| `max_workers` | int | null | Parallel session workers | +| `overwrite_sessions` | bool | false | Re-run already-completed sessions | +| `benchmark_kwargs` | object | null | Extra kwargs passed to the benchmark | +| `agent_kwargs` | object | null | Extra kwargs passed to the agent | + +--- + +## Commands + +All batch commands accept one or more `--config` flags, each taking a file path or a glob pattern. + +```bash +exgentic batch --config path/to/config.json +exgentic batch --config "configs/*.json" +exgentic batch --config configs/run1.json --config configs/run2.json +``` + +--- + +### batch evaluate + +Run all configs sequentially, executing sessions and aggregating results. + +```bash +exgentic batch evaluate --config "configs/*.json" +``` + +Already-completed sessions are skipped unless `overwrite_sessions` is true in the config. This makes it safe to re-run after partial failures — only missing or failed sessions are executed. + +--- + +### batch execute + +Same as `batch evaluate` but skips the aggregation step. Use this when you want to run sessions and aggregate later. + +```bash +exgentic batch execute --config "configs/*.json" +exgentic batch aggregate --config "configs/*.json" # aggregate afterwards +``` + +--- + +### batch aggregate + +Aggregate results from already-completed sessions without running anything. + +```bash +exgentic batch aggregate --config "configs/*.json" +``` + +Useful when you have sessions from a previous run and want to recompute scores. + +--- + +### batch status + +Print a status table showing completion state for each config. + +```bash +exgentic batch status --config "configs/*.json" +``` + +--- + +### batch prepare + +Write session config files to disk without executing. Creates the session directory structure so you can inspect or modify configs before running. + +```bash +exgentic batch prepare --config run.json +exgentic batch prepare --config run.json --overwrite # overwrite existing session configs +``` + +--- + +### batch patch + +Modify existing run or session config files in bulk using dotted-key notation. + +```bash +# Preview what would change +exgentic batch patch --config "configs/*.json" \ + --set model=gpt-4o \ + --dry-run + +# Apply changes +exgentic batch patch --config "configs/*.json" \ + --set model=gpt-4o \ + --set agent_kwargs.model_settings.temperature=0.2 \ + --apply +``` + +Dotted paths are resolved into nested dicts. Values are parsed as JSON first; if that fails, treated as strings. This lets you do sweeps: + +```bash +# Change model across a whole grid of configs +exgentic batch patch --config "sweep_*.json" --set model=claude-3-5-sonnet-20241022 --apply +``` + +--- + +### batch extract + +Export results from multiple runs into a single CSV file. + +```bash +exgentic batch extract --config "configs/*.json" --output results.csv +exgentic batch extract --config "configs/*.json" --output - # print to stdout +``` + +Each row is one run. Columns include all `RunResults` fields (see [Output Format](./output-format.md)). + +--- + +### batch publish + +Push results to a [HuggingFace dataset](https://huggingface.co/docs/datasets/). + +```bash +exgentic batch publish \ + --config "configs/*.json" \ + --repo Exgentic/open-agent-leaderboard-results \ + --append +``` + +Flags: + +| Flag | Default | Description | +|------|---------|-------------| +| `--repo` | required | HuggingFace dataset repo ID | +| `--append` / `--overwrite` | `--append` | Append to or replace the existing dataset | +| `--private` / `--public` | `--private` | Dataset visibility | + +Deduplication: when appending, existing rows with the same `(benchmark, agent, model)` triple are replaced. New combinations are appended. + +Requires the `datasets` package (`pip install datasets`) and a HuggingFace token with write access: + +```bash +huggingface-cli login +# or +export HF_TOKEN=hf_... +``` + +--- + +## Typical workflows + +### Parameter sweep + +```bash +# Create one config per model +for model in gpt-4o claude-3-5-sonnet-20241022 gemini-2.0-flash; do + cp base_config.json "configs/${model}.json" + exgentic batch patch --config "configs/${model}.json" --set model=${model} --apply +done + +# Run all +exgentic batch evaluate --config "configs/*.json" + +# Export to CSV +exgentic batch extract --config "configs/*.json" --output sweep_results.csv +``` + +### Resume after partial failure + +```bash +# Just re-run — completed sessions are skipped automatically +exgentic batch evaluate --config "configs/*.json" +``` + +### Separate execute from aggregate + +```bash +# Run sessions in parallel across machines, then aggregate centrally +exgentic batch execute --config "configs/*.json" +# ... copy outputs to aggregation machine ... +exgentic batch aggregate --config "configs/*.json" +``` + +--- + +## See also + +- [CLI Reference](./cli-reference.md) — full flag reference for all commands +- [Output Format](./output-format.md) — RunResults schema, what batch extract produces +- [Python API](./python-api.md) — programmatic equivalents: `evaluate()`, `execute()`, `aggregate()` +- [docs/](./README.md) — documentation index diff --git a/labs/AgentStream/exgentic/docs/cli-reference.md b/labs/AgentStream/exgentic/docs/cli-reference.md new file mode 100644 index 00000000..2c9235ba --- /dev/null +++ b/labs/AgentStream/exgentic/docs/cli-reference.md @@ -0,0 +1,323 @@ +# CLI Reference + +Complete reference for all `exgentic` CLI commands. + +**Related docs:** +[docs/](./README.md) · [Python API](./python-api.md) · [Batch Runs](./batch.md) · [Custom Models](./custom-models.md) · [Output Format](./output-format.md) + +--- + +## Global flags + +| Flag | Description | +|------|-------------| +| `--debug` | Enable debug logging | +| `--help` | Show help for any command | + +--- + +## Discovery + +### list benchmarks + +List all available benchmarks. + +```bash +exgentic list benchmarks +``` + +### list agents + +List all available agents. + +```bash +exgentic list agents +``` + +### list subsets + +List subsets for a benchmark. + +```bash +exgentic list subsets --benchmark tau2 +``` + +### list tasks + +List task IDs for a benchmark (or subset). + +```bash +exgentic list tasks --benchmark tau2 --subset retail +exgentic list tasks --benchmark tau2 --subset retail --limit 20 +``` + +| Flag | Description | +|------|-------------| +| `--benchmark` | Benchmark slug (required) | +| `--subset` | Subset name | +| `--limit` | Maximum tasks to show | + +--- + +## install + +Install a benchmark's or agent's dependencies (default: isolated venv). + +```bash +exgentic install --benchmark tau2 # install deps + data (default: venv) +exgentic install --agent tool_calling +exgentic install --benchmark tau2 --force # reinstall even if already set up +exgentic install --benchmark tau2 --docker # build Docker image +exgentic install --benchmark tau2 --local # install into local environment +``` + +| Flag | Description | +|------|-------------| +| `--benchmark` | Benchmark slug | +| `--agent` | Agent slug | +| `--force` | Force reinstall | +| `--docker` | Build a Docker image | +| `--local` | Install into the local environment instead of an isolated venv | + +See [Runners](./runners.md) for details on runner types. + +--- + +## uninstall + +Remove an installed benchmark's or agent's environment. + +```bash +exgentic uninstall --benchmark tau2 +exgentic uninstall --agent tool_calling +``` + +| Flag | Description | +|------|-------------| +| `--benchmark` | Benchmark slug | +| `--agent` | Agent slug | + +--- + +## setup (deprecated) + +> **Deprecated:** `exgentic setup` is an alias for `exgentic install` and will be removed in a future release. Use `install`/`uninstall` instead. + +--- + +## evaluate + +Run an evaluation end-to-end: execute sessions and aggregate results. + +```bash +exgentic evaluate \ + --benchmark tau2 \ + --agent tool_calling \ + --subset retail \ + --num-tasks 10 \ + --model gpt-4o \ + --set benchmark.user_simulator_model="gpt-4o" +``` + +| Flag | Description | +|------|-------------| +| `--benchmark` | Benchmark slug (required) | +| `--agent` | Agent slug (required) | +| `--subset` | Benchmark subset | +| `--task` | One or more specific task IDs (repeatable) | +| `--num-tasks` | Number of tasks to run | +| `--model` | Model override | +| `--max-steps` | Steps per session (default: 100) | +| `--max-actions` | Actions per session (default: 100) | +| `--max-workers` | Parallel session workers | +| `--overwrite` | Re-run already-completed sessions | +| `--output-dir` | Results output directory (default: `./outputs`) | +| `--run-id` | Override the auto-generated run ID | +| `--set KEY=VALUE` | Override any config field (repeatable) | +| `--debug` | Enable debug logging | + +### --set syntax + +`--set` accepts dotted key paths and JSON-compatible values: + +```bash +# Benchmark kwargs +--set benchmark.user_simulator_model="gpt-4o" +--set benchmark.runner=venv + +# Agent kwargs +--set agent.max_steps=200 + +# Model settings +--set agent.model.temperature=0.2 +--set agent.model.max_tokens=4096 +--set agent.model.top_p=0.9 +--set agent.model.reasoning_effort=high +--set agent.model.num_retries=3 +--set agent.model.retry_after=1.0 +--set agent.model.retry_strategy=constant +``` + +--- + +## status + +Show the execution status of a run (how many sessions are done, running, missing). + +```bash +exgentic status --benchmark tau2 --agent tool_calling --subset retail --num-tasks 10 +``` + +Accepts the same flags as `evaluate`. + +--- + +## preview + +Show which tasks would run without executing anything. + +```bash +exgentic preview --benchmark tau2 --agent tool_calling --subset retail --num-tasks 10 +``` + +Prints a plan showing which sessions would be new, which already exist, and which are currently running. + +--- + +## results + +Load and display results from a completed run. + +```bash +exgentic results --benchmark tau2 --agent tool_calling --subset retail --num-tasks 10 +``` + +Reads `results.json` from the run directory. Accepts the same config flags as `evaluate`. + +See [Output Format](./output-format.md) for the full results schema. + +--- + +## compare + +Statistical comparison between two run configurations. + +```bash +exgentic compare \ + --agents tool_calling openai_solo \ + --benchmark tau2 \ + --subset retail \ + --num-tasks 50 +``` + +Runs a Breslow-Day homogeneity test across subsets and reports whether the difference between agents is statistically significant. + +Requires the `analysis` extra: + +```bash +pip install "exgentic[analysis]" +``` + +--- + +## analyze + +Generate comparison plots for multiple benchmarks or agents. + +```bash +exgentic analyze \ + --agents tool_calling openai_solo \ + --benchmarks tau2 gsm8k \ + --output report.png +``` + +Requires the `analysis` extra: + +```bash +pip install "exgentic[analysis]" +``` + +--- + +## dashboard + +Launch the interactive web dashboard. + +```bash +exgentic dashboard +``` + +Opens a NiceGUI interface for exploring runs, browsing session trajectories, and monitoring live evaluations. + +--- + +## batch + +All batch subcommands. See [Batch Runs](./batch.md) for full documentation. + +```bash +exgentic batch evaluate --config "configs/*.json" +exgentic batch execute --config "configs/*.json" +exgentic batch aggregate --config "configs/*.json" +exgentic batch status --config "configs/*.json" +exgentic batch prepare --config run.json [--overwrite] +exgentic batch patch --config "configs/*.json" --set key=value [--apply | --dry-run] +exgentic batch extract --config "configs/*.json" --output results.csv +exgentic batch publish --config "configs/*.json" --repo org/dataset [--append | --overwrite] [--private | --public] +``` + +--- + +## Environment variables + +Exgentic reads the following environment variables. + +### Exgentic settings + +| Variable | Default | Description | +|----------|---------|-------------| +| `EXGENTIC_LOG_LEVEL` | `INFO` | Log level: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` | +| `EXGENTIC_CACHE_DIR` | `.exgentic` | Cache directory for venvs and setup state | +| `EXGENTIC_DOTENV_PATH` | `.env` | Path to `.env` file loaded automatically | +| `EXGENTIC_OTEL_ENABLED` | `false` | Enable OpenTelemetry tracing | +| `EXGENTIC_OTEL_RECORD_CONTENT` | `false` | Include prompts/responses in traces (opt-in) | +| `EXGENTIC_LITELLM_CACHING` | `true` | Enable LiteLLM response caching | +| `EXGENTIC_LITELLM_CACHE_DIR` | `~/.cache/exgentic/litellm` | LiteLLM cache directory | +| `EXGENTIC_LITELLM_LOG_LEVEL` | `WARNING` | LiteLLM internal log level | + +### LLM provider credentials + +| Variable | Provider | +|----------|----------| +| `OPENAI_API_KEY` | OpenAI | +| `ANTHROPIC_API_KEY` | Anthropic | +| `AZURE_API_KEY` | Azure OpenAI | +| `AZURE_API_BASE` | Azure OpenAI endpoint | +| `AZURE_API_VERSION` | Azure OpenAI API version | +| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | AWS Bedrock | +| `AWS_REGION_NAME` | AWS Bedrock region | +| `VERTEXAI_PROJECT` / `VERTEXAI_LOCATION` | Google Vertex AI | +| `OPENAI_API_BASE` | Custom OpenAI-compatible endpoint | + +See [Custom Models](./custom-models.md) for full provider setup instructions. + +### OpenTelemetry + +| Variable | Description | +|----------|-------------| +| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector endpoint | +| `OTEL_EXPORTER_OTLP_PROTOCOL` | `http/protobuf` or `grpc` | + +See [Observability Quick Start](./observability/quickstart.md) for tracing setup. + +--- + +## See also + +- [Python API](./python-api.md) — programmatic equivalents of all CLI commands +- [Batch Runs](./batch.md) — detailed guide for batch commands +- [Custom Models](./custom-models.md) — LLM provider and `--set agent.model.*` reference +- [Runners](./runners.md) — `--set benchmark.runner=*` options +- [Output Format](./output-format.md) — what `results` and `extract` produce +- [Observability Quick Start](./observability/quickstart.md) — tracing setup +- [docs/](./README.md) — documentation index diff --git a/labs/AgentStream/exgentic/docs/custom-models.md b/labs/AgentStream/exgentic/docs/custom-models.md new file mode 100644 index 00000000..f81be751 --- /dev/null +++ b/labs/AgentStream/exgentic/docs/custom-models.md @@ -0,0 +1,250 @@ +# Custom Models + +Exgentic routes all LLM calls through [LiteLLM](https://docs.litellm.ai/), which means any provider or deployment LiteLLM supports works out of the box — no code changes required. You pick the model, supply credentials, and optionally tune sampling parameters. + +**Related docs:** +[docs/](./README.md) · [CLI Reference](./cli-reference.md) · [Python API](./python-api.md) · [Adding Agents](./adding-agents.md) · [Observability Quick Start](./observability/quickstart.md) + +--- + +## Model string format + +The `--model` flag (and the `model` parameter in the Python API) accepts any model string that LiteLLM recognises. The general pattern is: + +``` +/ +``` + +For OpenAI-native models the provider prefix is optional: + +```bash +# These are equivalent +--model gpt-4o +--model openai/gpt-4o +``` + +For every other provider the prefix is required. See the provider examples below. + +--- + +## Supported providers + +### OpenAI + +```bash +exgentic evaluate --benchmark gsm8k --agent tool_calling \ + --model gpt-4o +``` + +Required environment variable: + +```bash +export OPENAI_API_KEY=sk-... +``` + +### Anthropic + +```bash +exgentic evaluate --benchmark gsm8k --agent tool_calling \ + --model claude-3-5-sonnet-20241022 +``` + +Required environment variable: + +```bash +export ANTHROPIC_API_KEY=sk-ant-... +``` + +### Azure OpenAI + +```bash +exgentic evaluate --benchmark gsm8k --agent tool_calling \ + --model azure/ +``` + +Required environment variables: + +```bash +export AZURE_API_KEY=... +export AZURE_API_BASE=https://.openai.azure.com +export AZURE_API_VERSION=2024-02-01 # or whichever version your deployment uses +``` + +### AWS Bedrock + +```bash +exgentic evaluate --benchmark gsm8k --agent tool_calling \ + --model bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0 +``` + +Required environment variables: + +```bash +export AWS_ACCESS_KEY_ID=... +export AWS_SECRET_ACCESS_KEY=... +export AWS_REGION_NAME=us-east-1 +``` + +### Google Vertex AI + +```bash +exgentic evaluate --benchmark gsm8k --agent tool_calling \ + --model vertex_ai/gemini-1.5-pro +``` + +Required environment variables: + +```bash +export VERTEXAI_PROJECT=my-gcp-project +export VERTEXAI_LOCATION=us-central1 +``` + +### Ollama (local) + +```bash +exgentic evaluate --benchmark gsm8k --agent tool_calling \ + --model ollama/llama3 +``` + +Required: Ollama running locally. Set the base URL if it differs from the default: + +```bash +export OLLAMA_API_BASE=http://localhost:11434 # default; only needed if different +``` + +### Any OpenAI-compatible endpoint + +```bash +exgentic evaluate --benchmark gsm8k --agent tool_calling \ + --model openai/ +``` + +Override the base URL: + +```bash +export OPENAI_API_BASE=http://localhost:8080/v1 +export OPENAI_API_KEY=any-non-empty-string # required by the client even if unused +``` + +This works with vLLM, LM Studio, LocalAI, Together AI, Fireworks, Anyscale, and any other OpenAI-compatible server. + +### LiteLLM proxy + +If you run a [LiteLLM proxy server](https://docs.litellm.ai/docs/proxy/quick_start) in front of your models: + +```bash +exgentic evaluate --benchmark gsm8k --agent tool_calling \ + --model openai/ +``` + +```bash +export OPENAI_API_BASE=http://localhost:4000 +export OPENAI_API_KEY= +``` + +--- + +## Sampling parameters + +Use `--set agent.model.*` to control sampling. These map to `ModelSettings` and are forwarded to LiteLLM on every completion call. + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--set agent.model.temperature` | float ≥ 0 | `1.0` | Sampling temperature | +| `--set agent.model.top_p` | float 0–1 | `null` | Nucleus sampling | +| `--set agent.model.max_tokens` | int ≥ 0 | `null` | Maximum tokens in response | +| `--set agent.model.reasoning_effort` | string | `null` | Reasoning effort level (o1/o3 models) | +| `--set agent.model.num_retries` | int ≥ 0 | `5` | Retries on transient errors | +| `--set agent.model.retry_after` | float ≥ 0 | `0.5` | Initial retry delay in seconds | +| `--set agent.model.retry_strategy` | string | `exponential_backoff` | `exponential_backoff` or `constant` | + +Example — lower temperature and capped output: + +```bash +exgentic evaluate --benchmark tau2 --agent tool_calling \ + --model gpt-4o \ + --set benchmark.user_simulator_model="gpt-4o" \ + --set agent.model.temperature=0.2 \ + --set agent.model.max_tokens=2048 +``` + +--- + +## Python API + +```python +from exgentic import evaluate +from exgentic.core.types import ModelSettings + +results = evaluate( + benchmark="tau2", + agent="tool_calling", + subset="retail", + num_tasks=5, + model="azure/my-gpt-4o-deployment", + agent_kwargs={ + "model_settings": ModelSettings( + temperature=0.2, + max_tokens=2048, + num_retries=3, + ) + }, + benchmark_kwargs={"user_simulator_model": "gpt-4o"}, +) +``` + +--- + +## Reasoning models + +For models that support reasoning effort (OpenAI o1, o3, etc.): + +```bash +exgentic evaluate --benchmark swebench --agent tool_calling \ + --model o3 \ + --set agent.model.reasoning_effort=high \ + --set agent.model.max_tokens=32768 +``` + +Note: temperature is typically fixed at 1 for reasoning models and will be ignored if set. + +--- + +## Cost tracking + +Exgentic records token counts and estimated cost for every LiteLLM completion automatically. Results appear in: + +- `outputs//results.json` — aggregate cost across all sessions +- `outputs//sessions//results.json` — per-session cost + +Cost estimates are calculated using LiteLLM's built-in pricing database. For providers or custom deployments not in the database, cost will show as `0`. + +--- + +## Caching + +LiteLLM-level response caching is enabled by default. To disable it for a run: + +```bash +export EXGENTIC_LITELLM_CACHING=false +``` + +The cache directory defaults to `.litellm_cache` in the working directory. To move it: + +```bash +export EXGENTIC_LITELLM_CACHE_DIR=/path/to/cache +``` + +--- + +## Observability + +All LLM inference calls emit OpenTelemetry spans automatically when tracing is enabled. See [Observability Quick Start](./observability/quickstart.md) to set up tracing, and [Semantic Conventions](./observability/semantic-conventions.md) for the full attribute reference. + +--- + +## Further reading + +- [LiteLLM providers documentation](https://docs.litellm.ai/docs/providers) +- [Adding a new agent adapter](./adding-agents.md) — relevant when wrapping a framework that manages its own LLM calls +- [Observability Quick Start](./observability/quickstart.md) diff --git a/labs/AgentStream/exgentic/docs/huggingface.md b/labs/AgentStream/exgentic/docs/huggingface.md new file mode 100644 index 00000000..05ce9104 --- /dev/null +++ b/labs/AgentStream/exgentic/docs/huggingface.md @@ -0,0 +1,35 @@ +# HuggingFace + +## Using HuggingFace Models + +Set your HF token and use the `huggingface///` model string format: + +```bash +export HF_TOKEN=hf_... +``` + +```bash +exgentic evaluate \ + --benchmark gsm8k \ + --agent tool_calling \ + --model huggingface/together/meta-llama/Llama-3.1-70B-Instruct +``` + +LiteLLM routes the call through HuggingFace's inference providers (billed to your HF account). Supported providers include `together`, `sambanova`, and others. Tool calling support depends on the provider and model. + +## Running on HuggingFace Jobs + +HuggingFace Jobs run containerized workloads on HF infrastructure (requires Pro/Team/Enterprise). + +```bash +hf jobs run astral-sh/uv:python3.12-bookworm sh -c " + uvx exgentic evaluate \ + --benchmark gsm8k \ + --agent tool_calling \ + --model huggingface/together/meta-llama/Llama-3.1-70B-Instruct \ + --output-dir /tmp/outputs && + uvx exgentic batch publish --repo-id your-org/eval-results /tmp/outputs +" --env HF_TOKEN=hf_... +``` + +Results are published to `https://huggingface.co/datasets/your-org/eval-results`. diff --git a/labs/AgentStream/exgentic/docs/observability/quickstart.md b/labs/AgentStream/exgentic/docs/observability/quickstart.md new file mode 100644 index 00000000..cfe52cde --- /dev/null +++ b/labs/AgentStream/exgentic/docs/observability/quickstart.md @@ -0,0 +1,151 @@ +# Observability Quick Start + +This guide gets you from zero to traces in five minutes using Jaeger as a local trace collector. + +For a full reference of every attribute Exgentic emits, see [Semantic Conventions](./semantic-conventions.md). + +--- + +## Prerequisites + +- Docker or Podman installed and running +- `exgentic` installed with the `otel` extra (see below) + +--- + +## Step 1 — Install the OTEL extra + +```bash +uv sync --extra otel +``` + +--- + +## Step 2 — Start Jaeger + +```bash +# Using Docker (or replace 'docker' with 'podman') +docker run -d --name jaeger \ + -e COLLECTOR_OTLP_ENABLED=true \ + -p 16686:16686 \ + -p 4317:4317 \ + -p 4318:4318 \ + jaegertracing/all-in-one:latest +``` + +Default ports: + +| Port | Service | +|-------|--------------| +| 16686 | Jaeger UI | +| 4317 | OTLP gRPC | +| 4318 | OTLP HTTP | + +--- + +## Step 3 — Configure environment variables + +```bash +export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 +export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf # or 'grpc' for port 4317 +export EXGENTIC_OTEL_ENABLED=true +``` + +To include task prompts, tool arguments, and LLM messages in traces (opt-in — may contain sensitive data): + +```bash +export EXGENTIC_OTEL_RECORD_CONTENT=true +``` + +--- + +## Step 4 — Set up and run an evaluation + +```bash +exgentic install --agent tool_calling +exgentic install --benchmark tau2 + +exgentic evaluate \ + --benchmark tau2 \ + --agent tool_calling \ + --model gpt-4o \ + --set benchmark.user_simulator_model="gpt-4o" \ + --task 1 \ + --max-steps 10 +``` + +--- + +## Step 5 — View traces + +Open [http://localhost:16686](http://localhost:16686), select the `exgentic` service, and click **Find Traces**. + +--- + +## Exporting traces + +### Via the Jaeger UI + +1. Open a trace. +2. Click the **JSON** button in the top-right corner. + +### Via the API + +```bash +# All recent traces +curl "http://localhost:16686/api/traces?service=exgentic&limit=100" | jq '.' > traces.json + +# A specific trace +curl "http://localhost:16686/api/traces/" | jq '.' > trace.json +``` + +--- + +## Troubleshooting + +### No traces appearing + +1. Verify environment variables are set: `env | grep OTEL` +2. Confirm Jaeger is running: `docker ps | grep jaeger` +3. Check Jaeger logs: `docker logs jaeger` +4. Ensure the evaluation completed successfully before looking for traces + +### Traces are incomplete + +- Wait a few seconds after the evaluation finishes — spans are flushed asynchronously +- Check session logs for OTEL-related errors +- Verify network connectivity to Jaeger + +### Jaeger not starting + +```bash +# Check for an existing container +docker ps -a | grep jaeger + +# Stop and remove, then restart +docker stop jaeger && docker rm jaeger +docker run -d --name jaeger \ + -e COLLECTOR_OTLP_ENABLED=true \ + -p 16686:16686 -p 4317:4317 -p 4318:4318 \ + jaegertracing/all-in-one:latest +``` + +--- + +## Cleanup + +```bash +docker stop jaeger +docker rm jaeger + +# Optional: remove the image +docker rmi jaegertracing/all-in-one:latest +``` + +--- + +## Further reading + +- [Semantic Conventions](./semantic-conventions.md) — full attribute reference +- [Jaeger documentation](https://www.jaegertracing.io/docs/) +- [OpenTelemetry GenAI conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/) diff --git a/labs/AgentStream/exgentic/docs/observability/semantic-conventions.md b/labs/AgentStream/exgentic/docs/observability/semantic-conventions.md new file mode 100644 index 00000000..92384e25 --- /dev/null +++ b/labs/AgentStream/exgentic/docs/observability/semantic-conventions.md @@ -0,0 +1,172 @@ +# Semantic Conventions + +This document maps Exgentic's core types to [OpenTelemetry GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/). It reflects the actual implementation in `src/exgentic/observers/handlers/otel.py` and `src/exgentic/integrations/litellm/trace_logger.py`. + +For setup instructions, see [Quick Start](./quickstart.md). + +--- + +## Span hierarchy + +``` +Session Span (ROOT) +├── execute_tool initial_observation +├── chat {model} ← LLM inference +├── execute_tool {tool_name} +├── chat {model} ← LLM inference +├── execute_tool {tool_name} +└── ... ← continues until session ends +``` + +--- + +## Attribute reference + +The table below documents every attribute actually emitted by the implementation, organised by span type. + +| Span type | OTel attribute | Exgentic source | Type | Requirement | Content-filtered | Notes | +|-----------|---------------|-----------------|------|-------------|-----------------|-------| +| **Session (ROOT)** | `exgentic.benchmark.slug_name` | `BenchmarkEntry.slug_name` | string | Custom | No | Heritable | +| **Session (ROOT)** | `exgentic.benchmark.subset` | `RunConfig.subset` | string | Custom | No | Heritable | +| **Session (ROOT)** | `exgentic.benchmark.agent.name` | `AgentEntry.display_name` | string | Custom | No | Heritable | +| **Session (ROOT)** | `exgentic.agent.slug` | `RunConfig.agent` | string | Custom | No | Heritable | +| **Session (ROOT)** | `exgentic.run.id` | `Context.run_id` | string | Custom | No | Heritable | +| **Session (ROOT)** | `gen_ai.request.model` | `RunConfig.model` | string | Recommended | No | Heritable; set when model is known at run start | +| **Session (ROOT)** | `gen_ai.conversation.id` | `Session.session_id` | string | Recommended | No | Heritable; primary correlation attribute | +| **Session (ROOT)** | `exgentic.session.id` | `Session.session_id` | string | Custom | No | Heritable; kept for backwards compatibility | +| **Session (ROOT)** | `exgentic.session.task_id` | `Session.task_id` | string | Custom | No | | +| **Session (ROOT)** | `exgentic.session.task` | `Session.task` | string | Opt-in | **Yes** | Task prompt; requires `EXGENTIC_OTEL_RECORD_CONTENT=true` | +| **Session (ROOT)** | `exgentic.session.action.{name}.name` | `ActionType.name` | string | Custom | No | One entry per action in `Session.actions` | +| **Session (ROOT)** | `exgentic.session.action.{name}.description` | `ActionType.description` | string | Custom | No | | +| **Session (ROOT)** | `exgentic.session.action.{name}.is_message` | `ActionType.is_message` | bool | Custom | No | | +| **Session (ROOT)** | `exgentic.session.action.{name}.is_finish` | `ActionType.is_finish` | bool | Custom | No | | +| **Session (ROOT)** | `exgentic.context.{key}` | `Session.context[key]` | string | Custom | No | One entry per context key | +| **Session (ROOT)** | `exgentic.session.agent.id` | `AgentInstance.agent_id` | string | Custom | No | | +| **Session (ROOT)** | `exgentic.session.agent.path` | `AgentInstance.paths.agent_dir` | string | Custom | No | | +| **Session (ROOT)** | `exgentic.score.success` | `SessionScore.success` | bool | Custom | No | Set on session close | +| **Session (ROOT)** | `exgentic.score` | `SessionScore.score` | float | Custom | No | Set on session close | +| **Session (ROOT)** | `exgentic.score.is_finished` | `SessionScore.is_finished` | bool | Custom | No | Set on session close | +| **Session (ROOT)** | `exgentic.session.steps` | step counter | int | Custom | No | Set on session close | +| **Session (ROOT)** | `exgentic.agent.agent_cost` | `AgentInstance.get_cost()` | string (JSON) | Custom | No | Set on session close | +| **Session (ROOT)** | `exgentic.session.cost` | `Session.get_cost()` | string (JSON) | Custom | No | Set on session close | +| **execute_tool** | `gen_ai.operation.name` | `"execute_tool"` | string | Required | No | Constant value | +| **execute_tool** | `gen_ai.tool.name` | `Action.name` | string | Required | No | | +| **execute_tool** | `gen_ai.tool.id` | `Action.id` | string | Recommended | No | | +| **execute_tool** | `gen_ai.tool.description` | `ActionType.description` | string | Recommended | No | Looked up from `Session.actions` | +| **execute_tool** | `gen_ai.tool.parameters` | `Action.arguments` | string (JSON) | Opt-in | **Yes** | Requires `EXGENTIC_OTEL_RECORD_CONTENT=true` | +| **execute_tool** | `gen_ai.tool.result` | `Observation` | string | Opt-in | **Yes** | Requires `EXGENTIC_OTEL_RECORD_CONTENT=true` | +| **execute_tool** | `gen_ai.conversation.id` | `Session.session_id` | string | Recommended | No | Inherited from session span | +| **LLM inference** | `gen_ai.operation.name` | `"chat"` or `"text_completion"` | string | Required | No | | +| **LLM inference** | `gen_ai.provider.name` | `litellm_params.custom_llm_provider` | string | Required | No | Mapped to standard provider names | +| **LLM inference** | `gen_ai.request.model` | `LitellmKwargs.model` | string | Required | No | | +| **LLM inference** | `error.type` | exception class name | string | Required | No | Set on failure | +| **LLM inference** | `gen_ai.conversation.id` | `Context.session_id` | string | Recommended | No | | +| **LLM inference** | `gen_ai.request.max_tokens` | `optional_params.max_tokens` | int | Recommended | No | | +| **LLM inference** | `gen_ai.request.temperature` | `optional_params.temperature` | float | Recommended | No | | +| **LLM inference** | `gen_ai.request.top_p` | `optional_params.top_p` | float | Recommended | No | | +| **LLM inference** | `gen_ai.request.top_k` | `optional_params.top_k` | float | Recommended | No | | +| **LLM inference** | `gen_ai.request.frequency_penalty` | `optional_params.frequency_penalty` | float | Recommended | No | | +| **LLM inference** | `gen_ai.request.presence_penalty` | `optional_params.presence_penalty` | float | Recommended | No | | +| **LLM inference** | `gen_ai.request.stop_sequences` | `optional_params.stop` | string[] | Recommended | No | | +| **LLM inference** | `gen_ai.request.choice.count` | `optional_params.n` | int | Required | No | Only when `n != 1` | +| **LLM inference** | `gen_ai.request.seed` | `optional_params.seed` | int | Required | No | | +| **LLM inference** | `gen_ai.response.id` | `ResponseObject.id` | string | Recommended | No | | +| **LLM inference** | `gen_ai.response.model` | `ResponseObject.model` | string | Recommended | No | Actual model resolved by the provider | +| **LLM inference** | `gen_ai.usage.input_tokens` | `usage.prompt_tokens` | int | Recommended | No | | +| **LLM inference** | `gen_ai.usage.output_tokens` | `usage.completion_tokens` | int | Recommended | No | | +| **LLM inference** | `gen_ai.response.finish_reasons` | `choices[*].finish_reason` | string[] | Recommended | No | | +| **LLM inference** | `gen_ai.tool.definitions` | `LitellmKwargs.tools` | string (JSON) | Opt-in | **Yes** | Requires `EXGENTIC_OTEL_RECORD_CONTENT=true` | +| **LLM inference** | `gen_ai.input.messages` | `LitellmKwargs.messages` | string (JSON) | Opt-in | **Yes** | Requires `EXGENTIC_OTEL_RECORD_CONTENT=true` | +| **LLM inference** | `gen_ai.output.messages` | `choices[*].message` | string (JSON) | Opt-in | **Yes** | Requires `EXGENTIC_OTEL_RECORD_CONTENT=true` | + +--- + +## Span details + +### Session span (ROOT) + +- **Name**: `{benchmark_name} {subset} session` +- **Kind**: `INTERNAL` +- **Opened**: `OtelTracingObserver.on_session_creation` +- **Closed**: `OtelTracingObserver.on_session_success` or `on_session_error` + +### execute_tool span + +- **Name**: `execute_tool {tool_name}` or `execute_tool initial_observation` +- **Kind**: `CLIENT` +- **Opened**: `OtelTracingObserver.on_session_start` (initial), `on_react_success`, or `on_react_error` +- **Closed**: `OtelTracingObserver.on_step_success` or `on_step_error` +- **Reference**: [OTel GenAI execute_tool span](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/#execute-tool-span) + +### LLM inference span + +- **Name**: `{operation} {model}` (e.g., `chat gpt-4o`) +- **Kind**: `CLIENT` +- **Opened/Closed**: `TraceLogger._write_otel` (LiteLLM callback) +- **Parent**: session span (via OTEL context propagation) +- **Reference**: [OTel GenAI inference span](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/#inference) + +--- + +## Attribute inheritance + +The following attributes are set on the session span and automatically propagated to all child spans via `SessionSpanManager.set_heritable_attribute()`: + +| Attribute | Source | +|-----------|--------| +| `gen_ai.conversation.id` | `Session.session_id` — primary correlation key | +| `exgentic.session.id` | `Session.session_id` — backwards compatibility | +| `gen_ai.request.model` | `RunConfig.model` (when available) | +| `exgentic.run.id` | `Context.run_id` | +| `exgentic.benchmark.slug_name` | `BenchmarkEntry.slug_name` | +| `exgentic.benchmark.subset` | `RunConfig.subset` | +| `exgentic.benchmark.agent.name` | `AgentEntry.display_name` | +| `exgentic.agent.slug` | `RunConfig.agent` | + +--- + +## Content filtering + +Attributes marked **Yes** in the content-filtered column contain user data (prompts, tool arguments, model responses). They are **not recorded by default** and must be explicitly enabled: + +```bash +export EXGENTIC_OTEL_RECORD_CONTENT=true +``` + +Attributes that are never filtered include all IDs, names, counters, scores, and static schemas — only runtime user content requires opt-in. + +--- + +## Implementation notes + +### Model name resolution + +Because `AgentInstance` does not expose model settings, the model name is extracted from `RunConfig` at run start: + +```python +model_name = run_config.model or (run_config.agent_kwargs or {}).get("model") +``` + +### Cost attributes + +`LiteLLMCostReport` and `UpdatableCostReport` are serialized to JSON strings for OTEL compatibility: + +- `exgentic.agent.agent_cost` — agent-level cost report +- `exgentic.session.cost` — full session cost report + +### LLM span parent context + +LLM inference spans are created inside the LiteLLM callback and attached to the session span via OTEL context propagation: + +1. The session span manager writes the current OTEL context into the `Context` ContextVar via `update_tracing_context()`. +2. The LiteLLM trace logger reads the OTEL context from that ContextVar. +3. LLM spans are created with the session span as their parent using `_get_parent_context()`. + +--- + +## References + +- [OTel GenAI Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/) +- [execute_tool span spec](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/#execute-tool-span) +- [Inference span spec](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/#inference) +- [Quick Start](./quickstart.md) diff --git a/labs/AgentStream/exgentic/docs/observers.md b/labs/AgentStream/exgentic/docs/observers.md new file mode 100644 index 00000000..bd29313b --- /dev/null +++ b/labs/AgentStream/exgentic/docs/observers.md @@ -0,0 +1,221 @@ +# Observers and Controllers + +Observers let you hook into the evaluation lifecycle to add custom logging, monitoring, alerting, or analytics — without modifying benchmarks or agents. Controllers extend this with the ability to stop a run early. + +**Related docs:** +[docs/](./README.md) · [Python API](./python-api.md) · [Output Format](./output-format.md) · [Observability](./observability/quickstart.md) + +--- + +## Observer interface + +All observers extend `exgentic.core.orchestrator.observer.Observer`. Every method has a default no-op implementation, so you only override what you need. + +```python +from exgentic.core.orchestrator.observer import Observer + +class Observer: + # Run-level callbacks + def on_run_start(self, run_config) -> None: ... + def on_run_success(self, results, run_config) -> None: ... + def on_run_error(self, error) -> None: ... + + # Session-level callbacks + def on_session_creation(self, session) -> None: ... + def on_session_start(self, session, agent, observation) -> None: ... + def on_session_scoring(self, session) -> None: ... + def on_session_success(self, session, score, agent) -> None: ... + def on_session_error(self, session, error) -> None: ... + def on_session_reuse(self, task_result) -> None: ... + + # Step-level: agent.react() returned an action + def on_react_success(self, session, action) -> None: ... + def on_react_error(self, session, error) -> None: ... + + # Step-level: session.step(action) returned an observation + def on_step_success(self, session, observation) -> None: ... + def on_step_error(self, session, error) -> None: ... +``` + +### Callback parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `run_config` | `RunConfig` | The run configuration | +| `results` | `RunResults` | Aggregated results (available in `on_run_success`) | +| `session` | `Session` | Session object with `session_id`, `task_id`, `paths` | +| `agent` | `Agent` | Agent config object with `get_cost()` | +| `observation` | `Observation \| None` | Observation returned by the benchmark (None on first step) | +| `action` | `Action \| None` | Action returned by the agent (None if agent is done) | +| `score` | `SessionScore` | Score with `score`, `success`, `is_finished`, `session_metrics`, `session_metadata` | +| `task_result` | `SessionResults` | Results for a session that was skipped/reused from cache | +| `error` | `Exception` | The exception that occurred | + +--- + +## Using observers + +Pass observers to `evaluate()`, `execute()`, or `aggregate()`: + +```python +from exgentic import evaluate + +results = evaluate( + benchmark="tau2", + agent="tool_calling", + num_tasks=10, + observers=[MyObserver()], +) +``` + +Multiple observers are supported: + +```python +observers=[LoggingObserver(), MetricsObserver(), AlertObserver()] +``` + +--- + +## Examples + +### Print session results as they complete + +```python +from exgentic.core.orchestrator.observer import Observer + +class PrintObserver(Observer): + def on_session_success(self, session, score, agent): + status = "PASS" if score.success else "FAIL" + print(f"[{status}] {session.task_id} score={score.score:.2f} cost=${agent.get_cost().total_cost:.4f}") + + def on_session_error(self, session, error): + print(f"[ERROR] {session.task_id} {type(error).__name__}: {error}") +``` + +### Track costs in real time + +```python +from exgentic.core.orchestrator.observer import Observer + +class CostTracker(Observer): + def __init__(self): + self.total_cost = 0.0 + + def on_session_success(self, session, score, agent): + self.total_cost += agent.get_cost().total_cost + print(f"Running total: ${self.total_cost:.4f}") +``` + +### Write a custom log file + +```python +import json +from pathlib import Path +from exgentic.core.orchestrator.observer import Observer + +class JsonlLogger(Observer): + def __init__(self, path: str): + self.path = Path(path) + + def on_session_success(self, session, score, agent): + entry = { + "session_id": session.session_id, + "task_id": session.task_id, + "success": score.success, + "score": score.score, + } + with self.path.open("a") as f: + f.write(json.dumps(entry) + "\n") +``` + +### Send a Slack alert on failure + +```python +import requests +from exgentic.core.orchestrator.observer import Observer + +class SlackAlerter(Observer): + def __init__(self, webhook_url: str): + self.webhook_url = webhook_url + + def on_session_error(self, session, error): + requests.post(self.webhook_url, json={ + "text": f":x: Session `{session.task_id}` failed: `{error}`" + }) +``` + +--- + +## Controllers + +Controllers extend observers with the ability to raise errors that stop the run. Use them to implement early stopping — e.g. abort if too many consecutive failures occur. + +```python +from exgentic.core.orchestrator.observer import Observer + +class EarlyStopController(Observer): + def __init__(self, max_failures: int = 3): + self.max_failures = max_failures + self.failures = 0 + + def on_session_error(self, session, error): + self.failures += 1 + if self.failures >= self.max_failures: + raise RuntimeError(f"Stopping: {self.failures} consecutive session errors") +``` + +Pass controllers via the `controllers` parameter: + +```python +results = evaluate( + benchmark="tau2", + agent="tool_calling", + num_tasks=50, + controllers=[EarlyStopController(max_failures=5)], +) +``` + +--- + +## Built-in observers + +Exgentic uses these observers internally. They run automatically — you do not need to register them. + +| Observer | What it does | +|----------|-------------| +| `ResultsObserver` | Writes `trajectory.jsonl` and `results.json` for every session; computes `RunResults` | +| `LoggerObserver` | Logs run progress to the console | +| `FileLoggerObserver` | Writes `run.log` and per-session logs | +| `OtelTracingObserver` | Emits OpenTelemetry spans (active when `EXGENTIC_OTEL_ENABLED=true`) | +| `DashboardEventsObserver` | Streams events to the live dashboard | +| `WarningsObserver` | Captures and writes `warnings.log` | +| `RecapObserver` | Prints a summary table at the end of a run | + +--- + +## Thread safety + +Observers may be called from multiple threads when `max_workers > 1`. If your observer maintains shared state (counters, file handles, accumulators), protect it with a lock: + +```python +import threading +from exgentic.core.orchestrator.observer import Observer + +class ThreadSafeCounter(Observer): + def __init__(self): + self._lock = threading.Lock() + self.count = 0 + + def on_session_success(self, session, score, agent): + with self._lock: + self.count += 1 +``` + +--- + +## See also + +- [Python API](./python-api.md) — how to pass observers to `evaluate()` +- [Observability Quick Start](./observability/quickstart.md) — OpenTelemetry tracing (built-in observer) +- [Output Format](./output-format.md) — the data that `ResultsObserver` writes +- [docs/](./README.md) — documentation index diff --git a/labs/AgentStream/exgentic/docs/output-format.md b/labs/AgentStream/exgentic/docs/output-format.md new file mode 100644 index 00000000..8d7e9eb7 --- /dev/null +++ b/labs/AgentStream/exgentic/docs/output-format.md @@ -0,0 +1,325 @@ +# Output Format + +Every evaluation writes structured results to a directory under `outputs//`. This document covers the file layout and the schema of every results file. + +**Related docs:** +[docs/](./README.md) · [Python API](./python-api.md) · [Batch Runs](./batch.md) · [CLI Reference](./cli-reference.md) + +--- + +## Directory layout + +``` +outputs// +├── results.json # Aggregated run-level results +├── benchmark_results.json # Benchmark-specific aggregated results +├── run/ +│ ├── config.json # Snapshot of RunConfig used for this run +│ ├── run.log # Main execution log +│ └── warnings.log # Warnings captured during execution +└── sessions// + ├── config.json # SessionConfig for this task + ├── results.json # Session-level results + ├── trajectory.jsonl # One JSON line per step (action + observation) + ├── agent/ + │ └── agent.log # Agent execution log + └── benchmark/ + ├── results.json # Benchmark-specific session results + └── session.log # Benchmark session log +``` + +### run_id and session_id + +Both IDs are deterministic SHA256 hashes of the configuration: + +- `run_id` — 12-character prefix of the hash of `(benchmark, agent, subset, model, benchmark_kwargs, agent_kwargs)` — everything except the task list. +- `session_id` — 8-character prefix of the hash of `(benchmark, agent, subset, task_id, model, benchmark_kwargs, agent_kwargs)` — includes the task, so each task gets a stable ID. + +This means re-running the same config produces the same IDs. Completed sessions are skipped unless `overwrite_sessions` is set. + +--- + +## results.json + +The top-level aggregated results for the entire run. + +```json +{ + "benchmark_name": "Tau2Bench", + "benchmark_slug_name": "tau2", + "agent_name": "LiteLLM Tool Calling", + "agent_slug_name": "tool_calling", + "model_name": "gpt-4o", + "model_names": ["gpt-4o"], + "subset_name": "retail", + "total_sessions": 10, + "planned_sessions": 10, + "successful_sessions": 7, + + "benchmark_score": 0.72, + "average_score": 0.65, + + "average_agent_cost": 0.043, + "total_agent_cost": 0.43, + "average_benchmark_cost": 0.021, + "total_benchmark_cost": 0.21, + "total_run_cost": 0.64, + + "average_steps": 12.4, + "average_action_count": 15.1, + "average_invalid_action_count": 0.8, + "average_invalid_action_percent": 5.3, + + "percent_finished": 80.0, + "percent_successful": 70.0, + "percent_finished_successful": 70.0, + "percent_finished_unsuccessful": 10.0, + "percent_unfinished": 10.0, + "percent_error": 10.0, + + "aggregation_mode": "completed_only", + "completed_sessions": 10, + "incomplete_sessions": 0, + "missing_sessions": 0, + "aggregated_session_ids": ["a1b2c3d4", "..."], + "skipped_session_ids": [], + "skipped_session_reasons": {}, + + "exgentic_version": "0.3.0", + + "session_results": [ ... ] +} +``` + +### Field reference + +#### Identity + +| Field | Type | Description | +|-------|------|-------------| +| `benchmark_name` | string | Benchmark display name | +| `benchmark_slug_name` | string | Benchmark CLI identifier | +| `agent_name` | string | Agent display name | +| `agent_slug_name` | string | Agent CLI identifier | +| `model_name` | string \| null | Primary model used | +| `model_names` | list[string] \| null | All models used (if multiple) | +| `subset_name` | string \| null | Benchmark subset | +| `exgentic_version` | string \| null | Exgentic version that produced these results | + +#### Session counts + +| Field | Type | Description | +|-------|------|-------------| +| `total_sessions` | int | Sessions that were executed | +| `planned_sessions` | int \| null | Sessions originally planned (from `num_tasks` or `task_ids`) | +| `successful_sessions` | int | Sessions where `success=true` | + +#### Scores + +| Field | Type | Description | +|-------|------|-------------| +| `benchmark_score` | float \| null | Primary score from `Benchmark.aggregate_sessions()` — benchmark-specific | +| `average_score` | float \| null | Mean of per-session `score` values | + +#### Costs + +| Field | Type | Description | +|-------|------|-------------| +| `average_agent_cost` | float \| null | Mean agent API cost per session (USD) | +| `total_agent_cost` | float \| null | Total agent API cost across all sessions | +| `average_benchmark_cost` | float \| null | Mean benchmark API cost per session (e.g. simulator LLM) | +| `total_benchmark_cost` | float \| null | Total benchmark API cost | +| `total_run_cost` | float \| null | Total cost (agent + benchmark) | + +#### Performance statistics + +| Field | Type | Description | +|-------|------|-------------| +| `average_steps` | float \| null | Mean number of steps per session | +| `average_action_count` | float \| null | Mean number of actions per session | +| `average_invalid_action_count` | float \| null | Mean invalid actions per session | +| `average_invalid_action_percent` | float \| null | Invalid actions as percentage of total | + +#### Outcome breakdown + +| Field | Type | Description | +|-------|------|-------------| +| `percent_finished` | float \| null | Sessions that reached a terminal state (success or failure) | +| `percent_successful` | float \| null | Sessions with `success=true` | +| `percent_finished_successful` | float \| null | Sessions that finished successfully | +| `percent_finished_unsuccessful` | float \| null | Sessions that finished unsuccessfully | +| `percent_unfinished` | float \| null | Sessions that ran out of steps | +| `percent_error` | float \| null | Sessions that raised an exception | + +#### Aggregation provenance + +| Field | Type | Description | +|-------|------|-------------| +| `aggregation_mode` | string \| null | Always `"completed_only"` — only completed sessions are aggregated | +| `completed_sessions` | int \| null | Sessions with a `results.json` on disk | +| `incomplete_sessions` | int \| null | Sessions with a directory but no `results.json` | +| `missing_sessions` | int \| null | Planned sessions with no directory at all | +| `aggregated_session_ids` | list[string] \| null | Sessions included in score aggregation | +| `skipped_session_ids` | list[string] \| null | Sessions excluded from aggregation | +| `skipped_session_reasons` | dict \| null | Reason per skipped session ID | + +--- + +## sessions//results.json + +Per-session results. + +```json +{ + "session_id": "a1b2c3d4", + "task_id": "retail_1", + "success": true, + "score": 1.0, + "is_finished": true, + "status": "success", + "steps": 14, + "action_count": 17, + "invalid_action_count": 1, + "agent_cost": 0.038, + "benchmark_cost": 0.019, + "execution_time": 42.3, + "details": { ... }, + "cost_reports": { + "agent": { "model_name": "gpt-4o", "input_tokens": 9400, "output_tokens": 820, ... }, + "benchmark": { ... } + } +} +``` + +### Field reference + +| Field | Type | Description | +|-------|------|-------------| +| `session_id` | string | 8-char deterministic session ID | +| `task_id` | string \| null | Task identifier from the benchmark | +| `success` | bool | Whether the session ended successfully | +| `score` | float \| null | Benchmark-assigned score for this session (0–1 unless benchmark uses a different scale) | +| `is_finished` | bool \| null | Whether the agent signalled completion (as opposed to hitting a step limit) | +| `status` | string | Session outcome status (see below) | +| `steps` | int | Number of (action → observation) steps executed | +| `action_count` | int | Total individual actions taken | +| `invalid_action_count` | int | Actions that failed schema or contract validation | +| `agent_cost` | float | Estimated agent API cost (USD) | +| `benchmark_cost` | float | Estimated benchmark API cost (USD) | +| `execution_time` | float | Wall-clock time in seconds | +| `details` | object | Full `SessionScore` dump — benchmark-specific | +| `cost_reports` | object | Detailed cost breakdown keyed by `"agent"` and `"benchmark"` | + +### Session outcome status + +| Value | Meaning | +|-------|---------| +| `success` | Session finished and benchmark scored it as successful | +| `unsuccessful` | Session finished but benchmark scored it as unsuccessful | +| `unfinished` | Agent never returned `None` — ran out of steps | +| `limit_reached` | Hit `max_steps` or `max_actions` | +| `error` | An exception occurred during execution | +| `cancelled` | Run was cancelled before this session completed | +| `unknown` | Status could not be determined | + +--- + +## sessions//trajectory.jsonl + +A newline-delimited JSON file with one entry per step. Use this to replay or audit what the agent did. + +```json +{"event": "observation", "step": 0, "initial": true, "session_id": "a1b2c3d4", "task_id": "retail_1", "observation": {...}, "action": null} +{"event": "action", "step": 1, "initial": false, "session_id": "a1b2c3d4", "task_id": "retail_1", "observation": null, "action": {"name": "search_products", "arguments": {...}}} +{"event": "observation", "step": 1, "initial": false, "session_id": "a1b2c3d4", "task_id": "retail_1", "observation": {"content": [...]}, "action": null} +... +``` + +--- + +## benchmark_results.json + +Benchmark-specific aggregated results produced by `Benchmark.aggregate_sessions()`. Schema is benchmark-defined, but always includes at minimum: + +```json +{ + "benchmark_name": "Tau2Bench", + "total_tasks": 10, + "score": 0.72, + "metrics": { ... } +} +``` + +--- + +## cost_reports schema + +Within session `results.json`, the `cost_reports` dict contains detailed token and cost breakdowns. + +```json +{ + "agent": { + "model_name": "gpt-4o", + "input_tokens": 9400, + "output_tokens": 820, + "input_cost": 0.0235, + "output_cost": 0.0164, + "total_cost": 0.0399 + }, + "benchmark": { + "model_name": "gpt-4o", + "input_tokens": 4200, + "output_tokens": 340, + "input_cost": 0.0105, + "output_cost": 0.0068, + "total_cost": 0.0173 + } +} +``` + +Cost estimates come from LiteLLM's pricing database. For providers or deployments not in the database, costs show as `0`. + +--- + +## Reading results programmatically + +```python +import json +from pathlib import Path + +run_dir = Path("outputs/abc123def456") + +# Load run-level results +results = json.loads((run_dir / "results.json").read_text()) +print(f"Score: {results['benchmark_score']}") +print(f"Sessions: {results['total_sessions']}") + +# Load a specific session trajectory +session_dir = run_dir / "sessions" / "a1b2c3d4" +trajectory = [ + json.loads(line) + for line in (session_dir / "trajectory.jsonl").read_text().splitlines() +] +``` + +Or use the Python API to load and validate: + +```python +from exgentic import results +from exgentic.batch import RunConfig + +config = RunConfig(benchmark="tau2", agent="tool_calling", subset="retail") +run_results = results(config) +print(run_results.benchmark_score) +``` + +See [Python API](./python-api.md) for the full API reference. + +--- + +## See also + +- [Python API](./python-api.md) — `results()`, `status()`, `aggregate()` functions +- [Batch Runs](./batch.md) — `batch extract` to export results to CSV +- [CLI Reference](./cli-reference.md) — `exgentic results` command +- [docs/](./README.md) — documentation index diff --git a/labs/AgentStream/exgentic/docs/python-api.md b/labs/AgentStream/exgentic/docs/python-api.md new file mode 100644 index 00000000..a8dc49b4 --- /dev/null +++ b/labs/AgentStream/exgentic/docs/python-api.md @@ -0,0 +1,361 @@ +# Python API + +Exgentic can be used as a library. The public API is importable directly from the `exgentic` package. + +**Related docs:** +[docs/](./README.md) · [CLI Reference](./cli-reference.md) · [Output Format](./output-format.md) · [Batch Runs](./batch.md) · [Custom Models](./custom-models.md) + +--- + +## Installation + +```bash +uv add exgentic # or: pip install exgentic +``` + +--- + +## Quick example + +```python +from exgentic import evaluate + +results = evaluate( + benchmark="tau2", + agent="tool_calling", + subset="retail", + num_tasks=5, + model="gpt-4o", + benchmark_kwargs={"user_simulator_model": "gpt-4o"}, +) + +print(results.benchmark_score) +print(results.total_agent_cost) +``` + +--- + +## Core functions + +All functions share the same config parameters. You can pass them as keyword arguments or as a pre-built `RunConfig` object. + +### evaluate() + +Run sessions and aggregate results. The standard function for most use cases. + +```python +from exgentic import evaluate + +results = evaluate( + benchmark="tau2", + agent="tool_calling", + subset="retail", + num_tasks=10, + model="gpt-4o", + benchmark_kwargs={"user_simulator_model": "gpt-4o"}, + agent_kwargs={"model_settings": {"temperature": 0.2}}, + max_steps=100, + max_actions=100, + max_workers=4, +) +``` + +Returns: `RunResults` — see [Output Format](./output-format.md) for the full schema. + +### execute() + +Run sessions without aggregating results. Use this when you want to separate execution from aggregation (e.g. run on multiple machines, aggregate centrally). + +```python +from exgentic import execute, aggregate + +execute(benchmark="tau2", agent="tool_calling", subset="retail", num_tasks=10) +# ... copy outputs to central machine ... +results = aggregate(benchmark="tau2", agent="tool_calling", subset="retail", num_tasks=10) +``` + +Returns: `RunResults` with aggregation fields empty. + +### aggregate() + +Aggregate already-completed sessions without running anything. Reads `results.json` from each session directory and computes run-level statistics. + +```python +from exgentic import aggregate + +results = aggregate( + benchmark="tau2", + agent="tool_calling", + subset="retail", + num_tasks=10, +) +``` + +Returns: `RunResults`. + +### status() + +Get the current execution status of a run without running anything. + +```python +from exgentic import status + +run_status = status(benchmark="tau2", agent="tool_calling", subset="retail", num_tasks=10) +print(run_status.completed) # number of completed sessions +print(run_status.running) # number of currently-running sessions +print(run_status.missing) # number of not-yet-started sessions +``` + +Returns: `RunStatus`. + +### preview() + +Get the execution plan for a run — which sessions would run, which would be reused, etc. — without executing. + +```python +from exgentic import preview +from exgentic.interfaces.lib.api import RunConfig + +config = RunConfig(benchmark="tau2", agent="tool_calling", subset="retail", num_tasks=10) +plan = preview(config) + +print(plan.to_run) # list of session configs that would run +print(plan.reuse) # list of already-completed sessions +print(plan.missing) # list of sessions with no output directory +``` + +Returns: `RunPlan`. + +### results() + +Load aggregated results from a completed run's `results.json` on disk. + +```python +from exgentic import results +from exgentic.interfaces.lib.api import RunConfig + +config = RunConfig(benchmark="tau2", agent="tool_calling", subset="retail", num_tasks=10) +run_results = results(config) +``` + +Returns: `RunResults`. + +--- + +## Parameters + +All core functions accept the same parameters (as kwargs or as a `RunConfig`/`SessionConfig` object). + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `benchmark` | string \| Benchmark | required | Benchmark slug or instance | +| `agent` | string \| Agent | required | Agent slug or instance | +| `subset` | string | null | Benchmark subset | +| `task_ids` | list[string] | null | Explicit task IDs | +| `num_tasks` | int | null | Number of tasks to run | +| `model` | string | null | Model override (forwarded to the agent) | +| `output_dir` | string | `./outputs` | Results directory | +| `cache_dir` | string | null | Cache directory | +| `run_id` | string | auto | Deterministic run ID derived from config | +| `max_steps` | int | 100 | Maximum steps per session | +| `max_actions` | int | 100 | Maximum actions per session | +| `max_workers` | int | null | Parallel session workers | +| `overwrite_sessions` | bool | false | Re-run completed sessions | +| `benchmark_kwargs` | dict | null | Extra kwargs for the benchmark constructor | +| `agent_kwargs` | dict | null | Extra kwargs for the agent constructor | +| `observers` | list | null | Custom observers (see [Observers](./observers.md)) | +| `controllers` | list | null | Custom controllers (see [Observers](./observers.md)) | + +--- + +## Discovery functions + +### list_benchmarks() + +```python +from exgentic import list_benchmarks + +for b in list_benchmarks(): + print(b["slug_name"], b["display_name"], b["installed"]) +``` + +Returns: `list[dict]` with keys `slug_name`, `display_name`, `installed`, `installed_at`. + +### list_agents() + +```python +from exgentic import list_agents + +for a in list_agents(): + print(a["slug_name"], a["display_name"]) +``` + +Returns: `list[dict]` with keys `slug_name`, `display_name`, `installed`, `installed_at`. + +### list_subsets() + +```python +from exgentic import list_subsets + +subsets = list_subsets("tau2") +# ["retail", "airline", "banking"] +``` + +Returns: `list[str]`. + +### list_tasks() + +```python +from exgentic import list_tasks + +tasks = list_tasks(benchmark="tau2", subset="retail") +# ["retail_1", "retail_2", ...] +``` + +Returns: `list[str]`. + +--- + +## Setup functions + +### setup_benchmark() + +Install a benchmark's dependencies and run its `setup.sh`. Equivalent to `exgentic install --benchmark `. + +```python +from exgentic.interfaces.lib.api import setup_benchmark + +setup_benchmark("tau2") +setup_benchmark("tau2", force=True) # reinstall even if already set up +setup_benchmark("tau2", runner="venv") # install into isolated venv +``` + +### setup_agent() + +```python +from exgentic.interfaces.lib.api import setup_agent + +setup_agent("tool_calling") +setup_agent("tool_calling", force=True) +``` + +--- + +## Config objects + +Use config objects when you want to construct a run programmatically, save configs to disk, or pass them around. + +### RunConfig + +```python +from exgentic.interfaces.lib.api import RunConfig + +config = RunConfig( + benchmark="tau2", + agent="tool_calling", + subset="retail", + num_tasks=10, + model="gpt-4o", + max_steps=100, + benchmark_kwargs={"user_simulator_model": "gpt-4o"}, + agent_kwargs={"model_settings": {"temperature": 0.2}}, +) + +# Save to disk +import json +Path("my_run.json").write_text(config.model_dump_json(indent=2)) + +# Load from disk +config2 = RunConfig.model_validate_json(Path("my_run.json").read_text()) +``` + +### SessionConfig + +For single-task runs: + +```python +from exgentic.interfaces.lib.api import SessionConfig + +config = SessionConfig( + benchmark="tau2", + agent="tool_calling", + task_id="retail_1", + subset="retail", + model="gpt-4o", +) +``` + +--- + +## Model settings + +Pass model settings through `agent_kwargs`: + +```python +from exgentic import evaluate +from exgentic.core.types import ModelSettings + +results = evaluate( + benchmark="tau2", + agent="tool_calling", + subset="retail", + num_tasks=5, + model="gpt-4o", + agent_kwargs={ + "model_settings": ModelSettings( + temperature=0.2, + max_tokens=4096, + num_retries=3, + ) + }, +) +``` + +Or as a plain dict (equivalent): + +```python +agent_kwargs={ + "model_settings": { + "temperature": 0.2, + "max_tokens": 4096, + "num_retries": 3, + } +} +``` + +See [Custom Models](./custom-models.md) for the full `ModelSettings` reference. + +--- + +## Custom observers + +Pass observers to receive live callbacks during a run: + +```python +from exgentic import evaluate +from exgentic.core.orchestrator.observer import Observer + +class PrintObserver(Observer): + def on_session_success(self, session, score, agent): + print(f"Session {session.session_id}: score={score.score}") + +results = evaluate( + benchmark="tau2", + agent="tool_calling", + num_tasks=5, + observers=[PrintObserver()], +) +``` + +See [Observers](./observers.md) for the full interface reference. + +--- + +## See also + +- [Custom Models](./custom-models.md) — LLM provider setup and `ModelSettings` +- [Output Format](./output-format.md) — `RunResults` and `SessionResults` schema +- [Observers](./observers.md) — custom event callbacks +- [Batch Runs](./batch.md) — programmatic equivalents of batch commands +- [CLI Reference](./cli-reference.md) — CLI alternative to the Python API +- [docs/](./README.md) — documentation index diff --git a/labs/AgentStream/exgentic/docs/replay-testing.md b/labs/AgentStream/exgentic/docs/replay-testing.md new file mode 100644 index 00000000..996be3b0 --- /dev/null +++ b/labs/AgentStream/exgentic/docs/replay-testing.md @@ -0,0 +1,229 @@ +# Replay Testing + +Replay tests let you verify benchmark and agent integration end-to-end without making real API calls or running external services. A recording captures a live session; the test replays it deterministically. + +This is the primary testing pattern for contributors adding new benchmarks or agents. + +**Related docs:** +[docs/](./README.md) · [Adding Benchmarks](./adding-benchmarks.md) · [Adding Agents](./adding-agents.md) · [Runners](./runners.md) + +--- + +## How it works + +1. Run a live evaluation and capture the session trajectory. +2. Store the trajectory in `tests/benchmarks/recordings//`. +3. Write a test that replays the trajectory using `ReplayAgent` and `ReplayBenchmark`. +4. The test verifies the session ends with the expected score — no network, no API keys, no benchmark installation required. + +Replay tests can be parametrized across all runner types (`direct`, `venv`, `docker`) to verify that runner isolation doesn't change session outcomes. + +--- + +## Recording format + +Each recording lives in its own directory: + +``` +tests/benchmarks/recordings// +├── recording.json # Metadata: task_id, benchmark slug, expected score +├── trajectory.jsonl # Recorded actions and observations +├── session.json # Session manifest +└── results.json # Recorded session results +``` + +### recording.json + +```json +{ + "task_id": "retail_1", + "benchmark_slug": "tau2", + "expected_score": 1.0 +} +``` + +Set `expected_score` to `null` if you only want to verify that the session completes without asserting on score. + +### trajectory.jsonl + +Newline-delimited JSON. Each line is one event — either an observation (benchmark → agent) or an action (agent → benchmark): + +```json +{"event": "observation", "step": 0, "initial": true, "session_id": "...", "task_id": "retail_1", "observation": {...}, "action": null} +{"event": "action", "step": 1, "initial": false, "session_id": "...", "task_id": "retail_1", "observation": null, "action": {"name": "search", "arguments": {...}}} +{"event": "observation", "step": 1, "initial": false, "session_id": "...", "task_id": "retail_1", "observation": {...}, "action": null} +``` + +--- + +## Creating a recording + +Run a live evaluation and save the trajectory. The trajectory file is written automatically to the session output directory: + +``` +outputs//sessions//trajectory.jsonl +outputs//sessions//results.json +``` + +Copy the relevant files into your recording directory: + +```bash +mkdir -p tests/benchmarks/recordings/my_benchmark + +cp outputs//sessions//trajectory.jsonl \ + tests/benchmarks/recordings/my_benchmark/ + +cp outputs//sessions//results.json \ + tests/benchmarks/recordings/my_benchmark/ + +# Write recording.json manually +cat > tests/benchmarks/recordings/my_benchmark/recording.json <=0.1.0 +some-other-dep==1.2.3 +``` + +Git LFS objects are automatically skipped during install (`GIT_LFS_SKIP_SMUDGE=1`). + +### setup.sh + +Shell script for setup that can't be expressed as pip packages: cloning repositories, compiling binaries, downloading model weights, etc. + +```bash +#!/usr/bin/env bash +set -euo pipefail + +# Example: clone a dependency +git clone --depth 1 https://github.com/example/repo /opt/repo +``` + +Place it in the same directory as your benchmark module. It runs after `requirements.txt` is installed. + +--- + +## See also + +- [Adding Benchmarks](./adding-benchmarks.md) — how benchmarks declare their setup +- [Adding Agents](./adding-agents.md) — how agents declare their setup +- [Custom Models](./custom-models.md) — configuring the LLM behind the agent +- [docs/](./README.md) — documentation index diff --git a/labs/AgentStream/exgentic/examples/run_appworld.py b/labs/AgentStream/exgentic/examples/run_appworld.py new file mode 100644 index 00000000..03600313 --- /dev/null +++ b/labs/AgentStream/exgentic/examples/run_appworld.py @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +## CLI usage: +# exgentic evaluate --benchmark appworld --agent tool_calling --subset test_normal --num-tasks 3 \ +# --model gpt-4o +## Python API usage: +# from exgentic import RunConfig, evaluate +# evaluate(RunConfig(benchmark="appworld", agent="tool_calling", subset="test_normal", num_tasks=3, +# model="gpt-4o")) +## Direct class usage (this script): +# AppWorldBenchmark + LiteLLMToolCallingAgent + +from exgentic import AppWorldBenchmark, LiteLLMToolCallingAgent, evaluate + + +def main() -> None: + benchmark = AppWorldBenchmark(subset="test_normal") + agent = LiteLLMToolCallingAgent(model="gpt-4o") + evaluate(benchmark=benchmark, agent=agent, output_dir="./outputs", num_tasks=3) + + +if __name__ == "__main__": + main() diff --git a/labs/AgentStream/exgentic/examples/run_browsecomp.py b/labs/AgentStream/exgentic/examples/run_browsecomp.py new file mode 100644 index 00000000..26176885 --- /dev/null +++ b/labs/AgentStream/exgentic/examples/run_browsecomp.py @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +## CLI usage: +# exgentic evaluate --benchmark browsecompplus --agent tool_calling --subset main --num-tasks 3 \ +# --model gpt-4o +## Python API usage: +# from exgentic import RunConfig, evaluate +# evaluate(RunConfig(benchmark="browsecompplus", agent="tool_calling", subset="main", num_tasks=3, +# model="gpt-4o")) +## Direct class usage (this script): +# BrowseCompPlusBenchmark + LiteLLMToolCallingAgent + +from exgentic import BrowseCompPlusBenchmark, LiteLLMToolCallingAgent, evaluate + + +def main() -> None: + benchmark = BrowseCompPlusBenchmark() + agent = LiteLLMToolCallingAgent(model="gpt-4o") + evaluate(benchmark=benchmark, agent=agent, output_dir="./outputs", num_tasks=3) + + +if __name__ == "__main__": + main() diff --git a/labs/AgentStream/exgentic/examples/run_claude_code_on_gsm8k.py b/labs/AgentStream/exgentic/examples/run_claude_code_on_gsm8k.py new file mode 100644 index 00000000..6e3cecb8 --- /dev/null +++ b/labs/AgentStream/exgentic/examples/run_claude_code_on_gsm8k.py @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +## CLI usage: +# exgentic evaluate --benchmark gsm8k --agent claude_code --num-tasks 1 \ +# --model gpt-4o +## Python API usage: +# from exgentic import RunConfig, evaluate +# evaluate(RunConfig(benchmark="gsm8k", agent="claude_code", num_tasks=1, +# model="gpt-4o")) +## Direct class usage (this script): +# GSM8kBenchmark + ClaudeCodeAgent + +from exgentic import ClaudeCodeAgent, GSM8kBenchmark, evaluate + + +def main() -> None: + benchmark = GSM8kBenchmark() + agent = ClaudeCodeAgent(model="gpt-4o") + evaluate(benchmark=benchmark, agent=agent, output_dir="./outputs", num_tasks=1) + + +if __name__ == "__main__": + main() diff --git a/labs/AgentStream/exgentic/examples/run_claude_code_on_tau2bench.py b/labs/AgentStream/exgentic/examples/run_claude_code_on_tau2bench.py new file mode 100644 index 00000000..b8e7b66e --- /dev/null +++ b/labs/AgentStream/exgentic/examples/run_claude_code_on_tau2bench.py @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +## CLI usage: +# exgentic evaluate --benchmark tau2 --agent claude_code --subset telecom --num-tasks 1 \ +# --model gpt-4o --set benchmark.user_simulator_model=gpt-4o +## Python API usage: +# from exgentic import RunConfig, evaluate +# evaluate(RunConfig(benchmark="tau2", agent="claude_code", subset="telecom", num_tasks=1, +# model="gpt-4o", benchmark_kwargs={"user_simulator_model": "gpt-4o"})) +## Direct class usage (this script): +# TAU2Benchmark + ClaudeCodeAgent + +from exgentic import ClaudeCodeAgent, TAU2Benchmark, evaluate + + +def main() -> None: + benchmark = TAU2Benchmark(subset="telecom", user_simulator_model="gpt-4o") + agent = ClaudeCodeAgent(model="gpt-4o") + evaluate(benchmark=benchmark, agent=agent, output_dir="./outputs", num_tasks=1) + + +if __name__ == "__main__": + main() diff --git a/labs/AgentStream/exgentic/examples/run_cli_agents.py b/labs/AgentStream/exgentic/examples/run_cli_agents.py new file mode 100644 index 00000000..cbf518c9 --- /dev/null +++ b/labs/AgentStream/exgentic/examples/run_cli_agents.py @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +## CLI usage: +# exgentic evaluate --benchmark gsm8k --agent codex_cli --num-tasks 3 \ +# --model gpt-4o +## Python API usage: +# from exgentic import RunConfig, evaluate +# evaluate(RunConfig(benchmark="gsm8k", agent="codex_cli", num_tasks=3, +# model="gpt-4o")) +## Direct class usage (this script): +# GSM8kBenchmark + CodexAgent + +from exgentic import CodexAgent, GSM8kBenchmark, evaluate + + +def main() -> None: + benchmark = GSM8kBenchmark() + agent = CodexAgent(model="gpt-4o") + evaluate(benchmark=benchmark, agent=agent, output_dir="./outputs", num_tasks=3) + + +if __name__ == "__main__": + main() diff --git a/labs/AgentStream/exgentic/examples/run_gsm8k.py b/labs/AgentStream/exgentic/examples/run_gsm8k.py new file mode 100644 index 00000000..93466296 --- /dev/null +++ b/labs/AgentStream/exgentic/examples/run_gsm8k.py @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +## CLI usage: +# exgentic evaluate --benchmark gsm8k --agent tool_calling --num-tasks 3 --model gpt-4o +## Python API usage: +# from exgentic import RunConfig, evaluate +# evaluate(RunConfig(benchmark="gsm8k", agent="tool_calling", num_tasks=3, +# model="gpt-4o")) +## Direct class usage (this script): +# GSM8kBenchmark + LiteLLMToolCallingAgent + +from exgentic import GSM8kBenchmark, LiteLLMToolCallingAgent, evaluate + + +def main() -> None: + benchmark = GSM8kBenchmark() + agent = LiteLLMToolCallingAgent(model="gpt-4o") + evaluate(benchmark=benchmark, agent=agent, output_dir="./outputs", num_tasks=3) + + +if __name__ == "__main__": + main() diff --git a/labs/AgentStream/exgentic/examples/run_hotpotqa.py b/labs/AgentStream/exgentic/examples/run_hotpotqa.py new file mode 100644 index 00000000..eccf6563 --- /dev/null +++ b/labs/AgentStream/exgentic/examples/run_hotpotqa.py @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +## CLI usage: +# exgentic evaluate --benchmark hotpotqa --agent smolagents_tool --subset distractor --num-tasks 3 \ +# --model gpt-4o --set benchmark.with_search_tools=true +## Python API usage: +# from exgentic import RunConfig, evaluate +# evaluate(RunConfig(benchmark="hotpotqa", agent="smolagents_tool", subset="distractor", num_tasks=3, +# model="gpt-4o", benchmark_kwargs={"with_search_tools": True})) +## Direct class usage (this script): +# HotpotQABenchmark + SmolagentToolCallingAgent + +from exgentic import HotpotQABenchmark, SmolagentToolCallingAgent, evaluate + + +def main() -> None: + benchmark = HotpotQABenchmark(with_search_tools=True) + agent = SmolagentToolCallingAgent(model="gpt-4o") + evaluate(benchmark=benchmark, agent=agent, output_dir="./outputs", num_tasks=3) + + +if __name__ == "__main__": + main() diff --git a/labs/AgentStream/exgentic/examples/run_openai_mcp.py b/labs/AgentStream/exgentic/examples/run_openai_mcp.py new file mode 100644 index 00000000..08a2486b --- /dev/null +++ b/labs/AgentStream/exgentic/examples/run_openai_mcp.py @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +## CLI usage: +# exgentic evaluate --benchmark tau2 --agent openai_solo --subset retail --task 4 \ +# --model gpt-4o --set benchmark.user_simulator_model=gpt-4o +## Python API usage: +# from exgentic import RunConfig, evaluate +# evaluate(RunConfig(benchmark="tau2", agent="openai_solo", subset="retail", task_ids=["4"], +# model="gpt-4o", +# benchmark_kwargs={"user_simulator_model": "gpt-4o"})) +## Direct class usage (this script): +# TAU2Benchmark + OpenAIMCPAgent + +from exgentic import OpenAIMCPAgent, TAU2Benchmark, evaluate + + +def main() -> None: + benchmark = TAU2Benchmark( + subset="retail", + user_simulator_model="gpt-4o", + ) + agent = OpenAIMCPAgent(model="gpt-4o") + evaluate( + benchmark=benchmark, + agent=agent, + output_dir="./outputs", + task_ids=["4"], + ) + + +if __name__ == "__main__": + main() diff --git a/labs/AgentStream/exgentic/examples/run_smol.py b/labs/AgentStream/exgentic/examples/run_smol.py new file mode 100644 index 00000000..2b616b68 --- /dev/null +++ b/labs/AgentStream/exgentic/examples/run_smol.py @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +## CLI usage: +# exgentic evaluate --benchmark tau2 --agent smolagents_code --subset retail --num-tasks 30 \ +# --model gpt-4o --set benchmark.user_simulator_model=gpt-4o +## Python API usage: +# from exgentic import RunConfig, evaluate +# evaluate(RunConfig(benchmark="tau2", agent="smolagents_code", subset="retail", num_tasks=30, +# model="gpt-4o", benchmark_kwargs={"user_simulator_model": "gpt-4o"})) +## Direct class usage (this script): +# TAU2Benchmark + SmolagentCodeAgent + +from exgentic import SmolagentCodeAgent, TAU2Benchmark, evaluate + + +def main() -> None: + benchmark = TAU2Benchmark(subset="retail", user_simulator_model="gpt-4o") + agent = SmolagentCodeAgent(model="gpt-4o") + evaluate(benchmark=benchmark, agent=agent, output_dir="./outputs", num_tasks=30) + + +if __name__ == "__main__": + main() diff --git a/labs/AgentStream/exgentic/examples/run_swebench.py b/labs/AgentStream/exgentic/examples/run_swebench.py new file mode 100644 index 00000000..0d7d9492 --- /dev/null +++ b/labs/AgentStream/exgentic/examples/run_swebench.py @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +## CLI usage: +# exgentic evaluate --benchmark swebench --agent tool_calling --num-tasks 30 \ +# --model gpt-4o +## Python API usage: +# from exgentic import RunConfig, evaluate +# evaluate(RunConfig(benchmark="swebench", agent="tool_calling", num_tasks=30, +# model="gpt-4o")) +## Direct class usage (this script): +# SWEBenchBenchmark + LiteLLMToolCallingAgent + +from exgentic import LiteLLMToolCallingAgent, SWEBenchBenchmark, evaluate + + +def main() -> None: + benchmark = SWEBenchBenchmark() + agent = LiteLLMToolCallingAgent(model="gpt-4o") + evaluate(benchmark=benchmark, agent=agent, output_dir="./outputs", num_tasks=30) + + +if __name__ == "__main__": + main() diff --git a/labs/AgentStream/exgentic/examples/run_taubench.py b/labs/AgentStream/exgentic/examples/run_taubench.py new file mode 100644 index 00000000..c2a2f1bd --- /dev/null +++ b/labs/AgentStream/exgentic/examples/run_taubench.py @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +## CLI usage: +# exgentic evaluate --benchmark tau2 --agent tool_calling --subset retail --num-tasks 1 \ +# --model gpt-4o --set benchmark.user_simulator_model=gpt-4o +## Python API usage: +# from exgentic import RunConfig, evaluate +# evaluate(RunConfig(benchmark="tau2", agent="tool_calling", subset="retail", num_tasks=1, +# model="gpt-4o", benchmark_kwargs={"user_simulator_model": "gpt-4o"})) +## Direct class usage (this script): +# TAU2Benchmark + LiteLLMToolCallingAgent + +from exgentic import LiteLLMToolCallingAgent, TAU2Benchmark, evaluate + + +def main() -> None: + benchmark = TAU2Benchmark(subset="retail", user_simulator_model="gpt-4o") + agent = LiteLLMToolCallingAgent(model="gpt-4o") + evaluate(benchmark=benchmark, agent=agent, output_dir="./outputs", num_tasks=1) + + +if __name__ == "__main__": + main() diff --git a/labs/AgentStream/exgentic/examples/simple_test_agent/adapter.py b/labs/AgentStream/exgentic/examples/simple_test_agent/adapter.py new file mode 100644 index 00000000..1501ea4a --- /dev/null +++ b/labs/AgentStream/exgentic/examples/simple_test_agent/adapter.py @@ -0,0 +1,54 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from typing import Any, Dict, List, Optional + +from exgentic.core.agent import Agent +from exgentic.core.agent_instance import AgentInstance +from exgentic.core.types import ModelSettings + + +class SimpleTestAgentInstance(AgentInstance): + """Simple test agent that responds with basic actions.""" + + def __init__(self, session_id: str, task: str, context: Dict[str, Any], actions: List[str]): + super().__init__(session_id) + self.task = task + self.context = context or {} + self.actions = actions + self.step_count = 0 + + def react(self, observation: Optional[str]) -> Optional[str]: + """React to observation with simple response.""" + self.step_count += 1 + + if observation is None: + # First step + return f"Starting task: {self.task}" + + # Simple logic: respond a few times then finish + if self.step_count <= 2: + return f"Responding to: {observation}" + # Signal completion + return None + + def close(self): + pass + + +class SimpleTestAgent(Agent): + """Agent factory that creates simple test agents.""" + + display_name: str = "Simple Test Agent" + slug_name: str = "simple_test" + + def __init__(self, model_settings: ModelSettings | None = None) -> None: + if model_settings is not None and not isinstance(model_settings, ModelSettings): + raise ValueError("model_settings must be a ModelSettings instance.") + self.model_settings = model_settings + + def assign(self, task: str, context: Dict[str, Any], actions: List[str], session_id: str) -> AgentInstance: + return SimpleTestAgentInstance(session_id, task, context, actions) + + def get_models_names(self) -> List[str]: # type: ignore[override] + return [] diff --git a/labs/AgentStream/exgentic/examples/simple_test_agent/setup.sh b/labs/AgentStream/exgentic/examples/simple_test_agent/setup.sh new file mode 100644 index 00000000..5ddf8ed1 --- /dev/null +++ b/labs/AgentStream/exgentic/examples/simple_test_agent/setup.sh @@ -0,0 +1,3 @@ +#!/bin/bash +# No additional dependencies needed for simple test agent +echo "Simple test agent - no setup required" diff --git a/labs/AgentStream/exgentic/misc/assets/cli.png b/labs/AgentStream/exgentic/misc/assets/cli.png new file mode 100644 index 00000000..fb4f5ad1 Binary files /dev/null and b/labs/AgentStream/exgentic/misc/assets/cli.png differ diff --git a/labs/AgentStream/exgentic/misc/assets/exgentic_banner_black.png b/labs/AgentStream/exgentic/misc/assets/exgentic_banner_black.png new file mode 100644 index 00000000..30030776 Binary files /dev/null and b/labs/AgentStream/exgentic/misc/assets/exgentic_banner_black.png differ diff --git a/labs/AgentStream/exgentic/misc/assets/exgentic_banner_black_no_background.png b/labs/AgentStream/exgentic/misc/assets/exgentic_banner_black_no_background.png new file mode 100644 index 00000000..e5286cc7 Binary files /dev/null and b/labs/AgentStream/exgentic/misc/assets/exgentic_banner_black_no_background.png differ diff --git a/labs/AgentStream/exgentic/misc/assets/exgentic_banner_white.png b/labs/AgentStream/exgentic/misc/assets/exgentic_banner_white.png new file mode 100644 index 00000000..f9539385 Binary files /dev/null and b/labs/AgentStream/exgentic/misc/assets/exgentic_banner_white.png differ diff --git a/labs/AgentStream/exgentic/misc/assets/exgentic_banner_white_no_background.png b/labs/AgentStream/exgentic/misc/assets/exgentic_banner_white_no_background.png new file mode 100644 index 00000000..8f272db3 Binary files /dev/null and b/labs/AgentStream/exgentic/misc/assets/exgentic_banner_white_no_background.png differ diff --git a/labs/AgentStream/exgentic/misc/assets/exgentic_light.png b/labs/AgentStream/exgentic/misc/assets/exgentic_light.png new file mode 100644 index 00000000..844b2302 Binary files /dev/null and b/labs/AgentStream/exgentic/misc/assets/exgentic_light.png differ diff --git a/labs/AgentStream/exgentic/misc/assets/gui.png b/labs/AgentStream/exgentic/misc/assets/gui.png new file mode 100644 index 00000000..5997c98a Binary files /dev/null and b/labs/AgentStream/exgentic/misc/assets/gui.png differ diff --git a/labs/AgentStream/exgentic/misc/assets/icon.png b/labs/AgentStream/exgentic/misc/assets/icon.png new file mode 100644 index 00000000..8e838737 Binary files /dev/null and b/labs/AgentStream/exgentic/misc/assets/icon.png differ diff --git a/labs/AgentStream/exgentic/misc/assets/icon_black.png b/labs/AgentStream/exgentic/misc/assets/icon_black.png new file mode 100644 index 00000000..72092af3 Binary files /dev/null and b/labs/AgentStream/exgentic/misc/assets/icon_black.png differ diff --git a/labs/AgentStream/exgentic/misc/security/requirements/README.md b/labs/AgentStream/exgentic/misc/security/requirements/README.md new file mode 100644 index 00000000..05949575 --- /dev/null +++ b/labs/AgentStream/exgentic/misc/security/requirements/README.md @@ -0,0 +1,39 @@ +# Requirements Files + +This directory contains frozen requirements for each virtual environment. + +## Directories + +Each environment has its own directory with a `requirements.txt` file: + +- `core/requirements.txt` - Core environment with all optional dependencies +- `gsm8k/requirements.txt` - GSM8K benchmark environment +- `hotpotqa/requirements.txt` - HotpotQA benchmark environment +- `appworld/requirements.txt` - AppWorld benchmark environment +- `browsecompplus/requirements.txt` - BrowseComp+ benchmark environment +- `swebench/requirements.txt` - SWE-bench benchmark environment +- `tau2/requirements.txt` - TAU-2 benchmark environment + +## Usage + +To recreate an environment from a requirements file: + +```bash +# Create a new virtual environment +uv venv .venv --python 3.11 + +# Activate it +source .venv/bin/activate + +# Install from requirements +uv pip install -r misc/security/requirements/core/requirements.txt +``` + +## Notes + +- These files are generated automatically by `misc/security/setup_environments.sh` +- They represent the exact package versions installed in each environment +- Only PyPI packages are included (no git or local installs) +- Regenerate by re-running the setup script + +## Generation Date diff --git a/labs/AgentStream/exgentic/misc/security/requirements/appworld/requirements.txt b/labs/AgentStream/exgentic/misc/security/requirements/appworld/requirements.txt new file mode 100644 index 00000000..5a8934bc --- /dev/null +++ b/labs/AgentStream/exgentic/misc/security/requirements/appworld/requirements.txt @@ -0,0 +1,195 @@ +aiofiles==25.1.0 +aiohappyeyeballs==2.6.1 +aiohttp==3.13.3 +aiosignal==1.4.0 +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.12.1 +apscheduler==3.11.2 +asttokens==3.0.1 +attrs==25.4.0 +azure-core==1.38.2 +azure-identity==1.25.2 +azure-storage-blob==12.28.0 +backoff==2.2.1 +bidict==0.23.1 +boto3==1.40.76 +botocore==1.40.76 +certifi==2026.2.25 +cffi==2.0.0 +cfgv==3.5.0 +charset-normalizer==3.4.4 +click==8.3.1 +cloudpickle==3.1.2 +codespell==2.4.1 +colorama==0.4.6 +contourpy==1.3.3 +croniter==6.0.0 +cryptography==46.0.5 +cycler==0.12.1 +decorator==5.2.1 +detect-secrets==1.5.0 +diskcache==5.6.3 +distlib==0.4.0 +distro==1.9.0 +dnspython==2.8.0 +docutils==0.22.4 +email-validator==2.3.0 +execnet==2.1.2 +executing==2.2.1 +faker==40.5.1 +fastapi==0.135.1 +fastapi-login==1.10.3 +fastapi-sso==0.16.0 +fastuuid==0.14.0 +filelock==3.25.0 +fonttools==4.61.1 +freezegun==1.5.1 +frozenlist==1.8.0 +fsspec==2026.2.0 +googleapis-common-protos==1.72.0 +griffe==1.15.0 +grpcio==1.78.0 +gunicorn==23.0.0 +h11==0.16.0 +hf-xet==1.3.2 +httpcore==1.0.9 +httptools==0.7.1 +httpx==0.28.1 +httpx-sse==0.4.3 +huggingface-hub==0.36.2 +identify==2.6.17 +idna==3.11 +ifaddr==0.2.0 +importlib-metadata==8.7.1 +inflection==0.5.1 +iniconfig==2.3.0 +ipython==9.10.0 +ipython-pygments-lexers==1.1.1 +isodate==0.7.2 +itsdangerous==2.2.0 +jedi==0.19.2 +jinja2==3.1.6 +jiter==0.13.0 +jmespath==1.1.0 +json-schema-to-pydantic==0.4.9 +jsonref==1.1.0 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +kiwisolver==1.4.9 +libcst==1.8.6 +litellm==1.82.0 +litellm-enterprise==0.1.33 +litellm-proxy-extras==0.4.50 +markdown-it-py==4.0.0 +markdown2==2.5.5 +markupsafe==3.0.3 +matplotlib==3.10.8 +matplotlib-inline==0.2.1 +mcp==1.26.0 +mdurl==0.1.2 +msal==1.35.0 +msal-extensions==1.3.1 +multidict==6.7.1 +munch==4.0.0 +nicegui==3.8.0 +nodeenv==1.10.0 +numpy==2.4.2 +oauthlib==3.3.1 +openai==2.24.0 +openai-agents==0.10.3 +opentelemetry-api==1.39.1 +opentelemetry-exporter-otlp-proto-common==1.39.1 +opentelemetry-exporter-otlp-proto-grpc==1.39.1 +opentelemetry-exporter-otlp-proto-http==1.39.1 +opentelemetry-proto==1.39.1 +opentelemetry-sdk==1.39.1 +opentelemetry-semantic-conventions==0.60b1 +opentelemetry-semantic-conventions-ai==0.4.15 +orjson==3.11.7 +packaging==26.0 +pandas==3.0.1 +parso==0.8.6 +patsy==1.0.2 +pendulum==3.2.0 +pexpect==4.9.0 +pillow==12.1.1 +platformdirs==4.9.2 +pluggy==1.6.0 +polars==1.38.1 +polars-runtime-32==1.38.1 +polyfactory==3.3.0 +pre-commit==4.5.1 +prompt-toolkit==3.0.52 +propcache==0.4.1 +protobuf==6.33.5 +psutil==7.2.2 +ptyprocess==0.7.0 +pure-eval==0.2.3 +pycparser==3.0 +pydantic==2.12.5 +pydantic-core==2.41.5 +pydantic-extra-types==2.11.0 +pydantic-settings==2.13.1 +pygments==2.19.2 +pyjwt==2.11.0 +pynacl==1.6.2 +pyparsing==3.3.2 +pyroscope-io==0.8.16 +pytest==9.0.2 +pytest-asyncio==1.3.0 +pytest-mock==3.15.1 +pytest-xdist==3.8.0 +python-dateutil==2.9.0.post0 +python-discovery==1.1.0 +python-dotenv==1.2.2 +python-engineio==4.13.1 +python-multipart==0.0.22 +python-socketio==5.16.1 +pytz==2026.1.post1 +pyyaml==6.0.3 +redis==7.2.1 +referencing==0.37.0 +regex==2026.2.28 +requests==2.32.5 +rich==14.3.3 +rich-click==1.9.7 +rpds-py==0.30.0 +rq==2.7.0 +ruff==0.15.4 +s3transfer==0.14.0 +scipy==1.17.1 +shellingham==1.5.4 +simple-websocket==1.1.0 +six==1.17.0 +smolagents==1.24.0 +sniffio==1.3.1 +soundfile==0.12.1 +sqlalchemy==2.0.48 +sqlalchemy-utils==0.42.1 +sqlmodel==0.0.37 +sse-starlette==3.3.2 +stack-data==0.6.3 +starlette==0.52.1 +statsmodels==0.14.6 +tiktoken==0.12.0 +tokenizers==0.22.2 +tqdm==4.67.3 +traitlets==5.14.3 +typer==0.24.1 +types-requests==2.32.4.20260107 +typing-extensions==4.15.0 +typing-inspection==0.4.2 +tzdata==2025.3 +tzlocal==5.3.1 +urllib3==2.6.3 +uvicorn==0.41.0 +uvloop==0.21.0 +virtualenv==21.1.0 +watchfiles==1.1.1 +wcwidth==0.6.0 +websockets==15.0.1 +wsproto==1.3.2 +xxhash==3.6.0 +yarl==1.23.0 +zipp==3.23.0 diff --git a/labs/AgentStream/exgentic/misc/security/requirements/browsecompplus/requirements.txt b/labs/AgentStream/exgentic/misc/security/requirements/browsecompplus/requirements.txt new file mode 100644 index 00000000..c4cc59be --- /dev/null +++ b/labs/AgentStream/exgentic/misc/security/requirements/browsecompplus/requirements.txt @@ -0,0 +1,261 @@ +accelerate==1.12.0 +aiofile==3.9.0 +aiofiles==23.2.1 +aiohappyeyeballs==2.6.1 +aiohttp==3.13.3 +aiosignal==1.4.0 +annotated-doc==0.0.4 +annotated-types==0.7.0 +anthropic==0.84.0 +anyio==4.12.1 +apscheduler==3.11.2 +attrs==25.4.0 +audioread==3.1.0 +authlib==1.6.9 +av==16.1.0 +azure-core==1.38.2 +azure-identity==1.25.2 +azure-storage-blob==12.28.0 +backoff==2.2.1 +backports-tarfile==1.2.0 +beartype==0.22.9 +beautifulsoup4==4.14.3 +bidict==0.23.1 +blinker==1.9.0 +boto3==1.40.76 +botocore==1.40.76 +cachetools==7.0.2 +caio==0.9.25 +certifi==2026.2.25 +cffi==2.0.0 +cfgv==3.5.0 +charset-normalizer==3.4.4 +click==8.3.1 +cloudpickle==3.1.2 +codespell==2.4.1 +colorama==0.4.6 +contourpy==1.3.3 +croniter==6.0.0 +cryptography==46.0.5 +cycler==0.12.1 +cyclopts==4.6.0 +cython==3.2.4 +dashscope==1.25.13 +datasets==4.6.1 +decorator==5.2.1 +detect-secrets==1.5.0 +dill==0.4.0 +diskcache==5.6.3 +distlib==0.4.0 +distro==1.9.0 +dnspython==2.8.0 +docstring-parser==0.17.0 +docutils==0.22.4 +email-validator==2.3.0 +eval-type-backport==0.3.1 +exceptiongroup==1.3.1 +faiss-cpu==1.13.2 +fastapi==0.135.1 +fastapi-sso==0.21.0 +fastmcp==3.1.0 +fastuuid==0.14.0 +ffmpy==1.0.0 +filelock==3.25.0 +flask==3.1.3 +flatbuffers==25.12.19 +fonttools==4.61.1 +frozenlist==1.8.0 +fsspec==2026.2.0 +google-auth==2.48.0 +google-genai==1.65.0 +googleapis-common-protos==1.72.0 +gradio-client==1.8.0 +griffe==1.15.0 +groovy==0.1.2 +grpcio==1.78.0 +gunicorn==23.0.0 +h11==0.16.0 +hf-transfer==0.1.9 +hf-xet==1.3.2 +httpcore==1.0.9 +httptools==0.7.1 +httpx==0.28.1 +httpx-sse==0.4.3 +huggingface-hub==0.36.2 +identify==2.6.17 +idna==3.11 +ifaddr==0.2.0 +importlib-metadata==8.7.1 +iniconfig==2.3.0 +isodate==0.7.2 +itsdangerous==2.2.0 +jaraco-classes==3.4.0 +jaraco-context==6.1.0 +jaraco-functools==4.4.0 +jieba==0.42.1 +jinja2==3.1.6 +jiter==0.13.0 +jmespath==1.1.0 +joblib==1.5.3 +json-schema-to-pydantic==0.4.9 +json5==0.13.0 +jsonlines==4.0.0 +jsonref==1.1.0 +jsonschema==4.26.0 +jsonschema-path==0.4.4 +jsonschema-specifications==2025.9.1 +keyring==25.7.0 +kiwisolver==1.4.9 +lazy-loader==0.4 +librosa==0.11.0 +litellm==1.82.0 +litellm-enterprise==0.1.33 +litellm-proxy-extras==0.4.50 +llvmlite==0.46.0 +lxml==6.0.2 +markdown-it-py==4.0.0 +markdown2==2.5.5 +markupsafe==3.0.3 +matplotlib==3.10.8 +mcp==1.26.0 +mdurl==0.1.2 +modelscope-studio==1.1.7 +more-itertools==10.8.0 +mpmath==1.3.0 +msal==1.35.0 +msal-extensions==1.3.1 +msgpack==1.1.2 +multidict==6.7.1 +multiprocess==0.70.18 +networkx==3.6.1 +nicegui==3.8.0 +nodeenv==1.10.0 +numba==0.64.0 +numpy==2.4.2 +oauthlib==3.3.1 +onnxruntime==1.24.2 +openai==2.24.0 +openai-agents==0.10.3 +openapi-pydantic==0.5.1 +opentelemetry-api==1.39.1 +opentelemetry-exporter-otlp-proto-common==1.39.1 +opentelemetry-exporter-otlp-proto-grpc==1.39.1 +opentelemetry-exporter-otlp-proto-http==1.39.1 +opentelemetry-proto==1.39.1 +opentelemetry-sdk==1.39.1 +opentelemetry-semantic-conventions==0.60b1 +opentelemetry-semantic-conventions-ai==0.4.15 +orjson==3.11.7 +packaging==26.0 +pandas==2.3.3 +pathable==0.5.0 +patsy==1.0.2 +pdfminer-six==20251230 +pdfplumber==0.11.9 +peft==0.18.1 +pillow==12.1.1 +platformdirs==4.9.2 +pluggy==1.6.0 +polars==1.38.1 +polars-runtime-32==1.38.1 +pooch==1.9.0 +pre-commit==4.5.1 +propcache==0.4.1 +protobuf==6.33.5 +psutil==7.2.2 +py-key-value-aio==0.4.4 +pyarrow==23.0.1 +pyasn1==0.6.2 +pyasn1-modules==0.4.2 +pycparser==3.0 +pydantic==2.12.5 +pydantic-core==2.41.5 +pydantic-settings==2.13.1 +pydub==0.25.1 +pygments==2.19.2 +pyjnius==1.7.0 +pyjwt==2.11.0 +pynacl==1.6.2 +pyngrok==7.5.0 +pyparsing==3.3.2 +pypdfium2==5.5.0 +pyperclip==1.11.0 +pyroscope-io==0.8.16 +pyserini==1.2.0 +pytest==9.0.2 +pytest-asyncio==1.3.0 +pytest-mock==3.15.1 +python-dateutil==2.9.0.post0 +python-discovery==1.1.0 +python-docx==1.2.0 +python-dotenv==1.2.2 +python-engineio==4.13.1 +python-multipart==0.0.22 +python-pptx==1.0.2 +python-socketio==5.16.1 +pytz==2026.1.post1 +pyyaml==6.0.3 +qwen-agent==0.0.27 +qwen-omni-utils==0.0.8 +rank-bm25==0.2.2 +redis==7.2.1 +referencing==0.37.0 +regex==2026.2.28 +requests==2.32.5 +rich==14.3.3 +rich-click==1.9.7 +rich-rst==1.3.2 +rpds-py==0.30.0 +rq==2.7.0 +rsa==4.9.1 +ruff==0.15.4 +s3transfer==0.14.0 +safehttpx==0.1.7 +safetensors==0.7.0 +scikit-learn==1.8.0 +scipy==1.17.1 +semantic-version==2.10.0 +sentencepiece==0.2.1 +shellingham==1.5.4 +simple-websocket==1.1.0 +six==1.17.0 +smolagents==1.24.0 +sniffio==1.3.1 +snowballstemmer==3.0.1 +soundfile==0.12.1 +soupsieve==2.8.3 +soxr==1.0.0 +sse-starlette==3.3.2 +starlette==0.52.1 +statsmodels==0.14.6 +sympy==1.14.0 +tabulate==0.9.0 +tenacity==9.1.4 +threadpoolctl==3.6.0 +tiktoken==0.12.0 +tokenizers==0.22.2 +tomlkit==0.13.3 +torch==2.10.0 +torchvision==0.25.0 +tqdm==4.67.3 +transformers==4.57.6 +typer==0.24.1 +types-requests==2.32.4.20260107 +typing-extensions==4.15.0 +typing-inspection==0.4.2 +tzdata==2025.3 +tzlocal==5.3.1 +uncalled-for==0.2.0 +urllib3==2.6.3 +uvicorn==0.41.0 +uvloop==0.21.0 +virtualenv==21.1.0 +watchfiles==1.1.1 +websocket-client==1.9.0 +websockets==16.0 +werkzeug==3.1.6 +wsproto==1.3.2 +xlsxwriter==3.2.9 +xxhash==3.6.0 +yarl==1.23.0 +zipp==3.23.0 diff --git a/labs/AgentStream/exgentic/misc/security/requirements/core/requirements.txt b/labs/AgentStream/exgentic/misc/security/requirements/core/requirements.txt new file mode 100644 index 00000000..1917cef5 --- /dev/null +++ b/labs/AgentStream/exgentic/misc/security/requirements/core/requirements.txt @@ -0,0 +1,160 @@ +aiofiles==25.1.0 +aiohappyeyeballs==2.6.1 +aiohttp==3.13.3 +aiosignal==1.4.0 +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.12.1 +apscheduler==3.11.2 +attrs==25.4.0 +azure-core==1.38.2 +azure-identity==1.25.2 +azure-storage-blob==12.28.0 +backoff==2.2.1 +bidict==0.23.1 +boto3==1.40.76 +botocore==1.40.76 +certifi==2026.2.25 +cffi==2.0.0 +cfgv==3.5.0 +charset-normalizer==3.4.4 +click==8.3.1 +cloudpickle==3.1.2 +codespell==2.4.1 +colorama==0.4.6 +contourpy==1.3.3 +croniter==6.0.0 +cryptography==46.0.5 +cycler==0.12.1 +detect-secrets==1.5.0 +diskcache==5.6.3 +distlib==0.4.0 +distro==1.9.0 +dnspython==2.8.0 +docutils==0.22.4 +email-validator==2.3.0 +fastapi==0.135.1 +fastapi-sso==0.16.0 +fastuuid==0.14.0 +filelock==3.25.0 +fonttools==4.61.1 +frozenlist==1.8.0 +fsspec==2026.2.0 +googleapis-common-protos==1.72.0 +griffe==1.15.0 +grpcio==1.78.0 +gunicorn==23.0.0 +h11==0.16.0 +hf-xet==1.3.2 +httpcore==1.0.9 +httptools==0.7.1 +httpx==0.28.1 +httpx-sse==0.4.3 +huggingface-hub==0.36.2 +identify==2.6.17 +idna==3.11 +ifaddr==0.2.0 +importlib-metadata==8.7.1 +iniconfig==2.3.0 +isodate==0.7.2 +itsdangerous==2.2.0 +jinja2==3.1.6 +jiter==0.13.0 +jmespath==1.1.0 +json-schema-to-pydantic==0.4.9 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +kiwisolver==1.4.9 +litellm==1.82.0 +litellm-enterprise==0.1.33 +litellm-proxy-extras==0.4.50 +markdown-it-py==4.0.0 +markdown2==2.5.5 +markupsafe==3.0.3 +matplotlib==3.10.8 +mcp==1.26.0 +mdurl==0.1.2 +msal==1.35.0 +msal-extensions==1.3.1 +multidict==6.7.1 +nicegui==3.8.0 +nodeenv==1.10.0 +numpy==2.4.2 +oauthlib==3.3.1 +openai==2.24.0 +openai-agents==0.10.3 +opentelemetry-api==1.39.1 +opentelemetry-exporter-otlp-proto-common==1.39.1 +opentelemetry-exporter-otlp-proto-grpc==1.39.1 +opentelemetry-exporter-otlp-proto-http==1.39.1 +opentelemetry-proto==1.39.1 +opentelemetry-sdk==1.39.1 +opentelemetry-semantic-conventions==0.60b1 +opentelemetry-semantic-conventions-ai==0.4.15 +orjson==3.11.7 +packaging==26.0 +pandas==3.0.1 +patsy==1.0.2 +pillow==12.1.1 +platformdirs==4.9.2 +pluggy==1.6.0 +polars==1.38.1 +polars-runtime-32==1.38.1 +pre-commit==4.5.1 +propcache==0.4.1 +protobuf==6.33.5 +pycparser==3.0 +pydantic==2.12.5 +pydantic-core==2.41.5 +pydantic-settings==2.13.1 +pygments==2.19.2 +pyjwt==2.11.0 +pynacl==1.6.2 +pyparsing==3.3.2 +pyroscope-io==0.8.16 +pytest==9.0.2 +pytest-asyncio==1.3.0 +pytest-mock==3.15.1 +python-dateutil==2.9.0.post0 +python-discovery==1.1.0 +python-dotenv==1.2.2 +python-engineio==4.13.1 +python-multipart==0.0.22 +python-socketio==5.16.1 +pytz==2026.1.post1 +pyyaml==6.0.3 +redis==7.2.1 +referencing==0.37.0 +regex==2026.2.28 +requests==2.32.5 +rich==14.3.3 +rich-click==1.9.7 +rpds-py==0.30.0 +rq==2.7.0 +ruff==0.15.4 +s3transfer==0.14.0 +scipy==1.17.1 +simple-websocket==1.1.0 +six==1.17.0 +smolagents==1.24.0 +sniffio==1.3.1 +soundfile==0.12.1 +sse-starlette==3.3.2 +starlette==0.52.1 +statsmodels==0.14.6 +tiktoken==0.12.0 +tokenizers==0.22.2 +tqdm==4.67.3 +types-requests==2.32.4.20260107 +typing-extensions==4.15.0 +typing-inspection==0.4.2 +tzlocal==5.3.1 +urllib3==2.6.3 +uvicorn==0.41.0 +uvloop==0.21.0 +virtualenv==21.1.0 +watchfiles==1.1.1 +websockets==15.0.1 +wsproto==1.3.2 +yarl==1.23.0 +zipp==3.23.0 diff --git a/labs/AgentStream/exgentic/misc/security/requirements/gsm8k/requirements.txt b/labs/AgentStream/exgentic/misc/security/requirements/gsm8k/requirements.txt new file mode 100644 index 00000000..13c240c8 --- /dev/null +++ b/labs/AgentStream/exgentic/misc/security/requirements/gsm8k/requirements.txt @@ -0,0 +1,165 @@ +aiofiles==25.1.0 +aiohappyeyeballs==2.6.1 +aiohttp==3.13.3 +aiosignal==1.4.0 +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.12.1 +apscheduler==3.11.2 +attrs==25.4.0 +azure-core==1.38.2 +azure-identity==1.25.2 +azure-storage-blob==12.28.0 +backoff==2.2.1 +bidict==0.23.1 +boto3==1.40.76 +botocore==1.40.76 +certifi==2026.2.25 +cffi==2.0.0 +cfgv==3.5.0 +charset-normalizer==3.4.4 +click==8.3.1 +cloudpickle==3.1.2 +codespell==2.4.1 +colorama==0.4.6 +contourpy==1.3.3 +croniter==6.0.0 +cryptography==46.0.5 +cycler==0.12.1 +datasets==4.6.1 +detect-secrets==1.5.0 +dill==0.4.0 +diskcache==5.6.3 +distlib==0.4.0 +distro==1.9.0 +dnspython==2.8.0 +docutils==0.22.4 +email-validator==2.3.0 +fastapi==0.135.1 +fastapi-sso==0.16.0 +fastuuid==0.14.0 +filelock==3.25.0 +fonttools==4.61.1 +frozenlist==1.8.0 +fsspec==2026.2.0 +googleapis-common-protos==1.72.0 +griffe==1.15.0 +grpcio==1.78.0 +gunicorn==23.0.0 +h11==0.16.0 +hf-xet==1.3.2 +httpcore==1.0.9 +httptools==0.7.1 +httpx==0.28.1 +httpx-sse==0.4.3 +huggingface-hub==0.36.2 +identify==2.6.17 +idna==3.11 +ifaddr==0.2.0 +importlib-metadata==8.7.1 +iniconfig==2.3.0 +isodate==0.7.2 +itsdangerous==2.2.0 +jinja2==3.1.6 +jiter==0.13.0 +jmespath==1.1.0 +json-schema-to-pydantic==0.4.9 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +kiwisolver==1.4.9 +litellm==1.82.0 +litellm-enterprise==0.1.33 +litellm-proxy-extras==0.4.50 +markdown-it-py==4.0.0 +markdown2==2.5.5 +markupsafe==3.0.3 +matplotlib==3.10.8 +mcp==1.26.0 +mdurl==0.1.2 +msal==1.35.0 +msal-extensions==1.3.1 +multidict==6.7.1 +multiprocess==0.70.18 +nicegui==3.8.0 +nodeenv==1.10.0 +numpy==2.4.2 +oauthlib==3.3.1 +openai==2.24.0 +openai-agents==0.10.3 +opentelemetry-api==1.39.1 +opentelemetry-exporter-otlp-proto-common==1.39.1 +opentelemetry-exporter-otlp-proto-grpc==1.39.1 +opentelemetry-exporter-otlp-proto-http==1.39.1 +opentelemetry-proto==1.39.1 +opentelemetry-sdk==1.39.1 +opentelemetry-semantic-conventions==0.60b1 +opentelemetry-semantic-conventions-ai==0.4.15 +orjson==3.11.7 +packaging==26.0 +pandas==3.0.1 +patsy==1.0.2 +pillow==12.1.1 +platformdirs==4.9.2 +pluggy==1.6.0 +polars==1.38.1 +polars-runtime-32==1.38.1 +pre-commit==4.5.1 +propcache==0.4.1 +protobuf==6.33.5 +pyarrow==23.0.1 +pycparser==3.0 +pydantic==2.12.5 +pydantic-core==2.41.5 +pydantic-settings==2.13.1 +pygments==2.19.2 +pyjwt==2.11.0 +pynacl==1.6.2 +pyparsing==3.3.2 +pyroscope-io==0.8.16 +pytest==9.0.2 +pytest-asyncio==1.3.0 +pytest-mock==3.15.1 +python-dateutil==2.9.0.post0 +python-discovery==1.1.0 +python-dotenv==1.2.2 +python-engineio==4.13.1 +python-multipart==0.0.22 +python-socketio==5.16.1 +pytz==2026.1.post1 +pyyaml==6.0.3 +redis==7.2.1 +referencing==0.37.0 +regex==2026.2.28 +requests==2.32.5 +rich==14.3.3 +rich-click==1.9.7 +rpds-py==0.30.0 +rq==2.7.0 +ruff==0.15.4 +s3transfer==0.14.0 +scipy==1.17.1 +simple-websocket==1.1.0 +six==1.17.0 +smolagents==1.24.0 +sniffio==1.3.1 +soundfile==0.12.1 +sse-starlette==3.3.2 +starlette==0.52.1 +statsmodels==0.14.6 +tiktoken==0.12.0 +tokenizers==0.22.2 +tqdm==4.67.3 +types-requests==2.32.4.20260107 +typing-extensions==4.15.0 +typing-inspection==0.4.2 +tzlocal==5.3.1 +urllib3==2.6.3 +uvicorn==0.41.0 +uvloop==0.21.0 +virtualenv==21.1.0 +watchfiles==1.1.1 +websockets==15.0.1 +wsproto==1.3.2 +xxhash==3.6.0 +yarl==1.23.0 +zipp==3.23.0 diff --git a/labs/AgentStream/exgentic/misc/security/requirements/hotpotqa/requirements.txt b/labs/AgentStream/exgentic/misc/security/requirements/hotpotqa/requirements.txt new file mode 100644 index 00000000..8a53efe6 --- /dev/null +++ b/labs/AgentStream/exgentic/misc/security/requirements/hotpotqa/requirements.txt @@ -0,0 +1,190 @@ +aiofile==3.9.0 +aiofiles==25.1.0 +aiohappyeyeballs==2.6.1 +aiohttp==3.13.3 +aiosignal==1.4.0 +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.12.1 +apscheduler==3.11.2 +attrs==25.4.0 +authlib==1.6.9 +azure-core==1.38.2 +azure-identity==1.25.2 +azure-storage-blob==12.28.0 +backoff==2.2.1 +backports-tarfile==1.2.0 +beartype==0.22.9 +bidict==0.23.1 +boto3==1.40.76 +botocore==1.40.76 +cachetools==7.0.2 +caio==0.9.25 +certifi==2026.2.25 +cffi==2.0.0 +cfgv==3.5.0 +charset-normalizer==3.4.4 +click==8.3.1 +cloudpickle==3.1.2 +codespell==2.4.1 +colorama==0.4.6 +contourpy==1.3.3 +croniter==6.0.0 +cryptography==46.0.5 +cycler==0.12.1 +cyclopts==4.6.0 +datasets==4.6.1 +detect-secrets==1.5.0 +dill==0.4.0 +diskcache==5.6.3 +distlib==0.4.0 +distro==1.9.0 +dnspython==2.8.0 +docstring-parser==0.17.0 +docutils==0.22.4 +email-validator==2.3.0 +exceptiongroup==1.3.1 +fastapi==0.135.1 +fastapi-sso==0.16.0 +fastmcp==3.1.0 +fastuuid==0.14.0 +filelock==3.25.0 +fonttools==4.61.1 +frozenlist==1.8.0 +fsspec==2026.2.0 +googleapis-common-protos==1.72.0 +griffe==1.15.0 +grpcio==1.78.0 +gunicorn==23.0.0 +h11==0.16.0 +hf-xet==1.3.2 +httpcore==1.0.9 +httptools==0.7.1 +httpx==0.28.1 +httpx-sse==0.4.3 +huggingface-hub==0.36.2 +identify==2.6.17 +idna==3.11 +ifaddr==0.2.0 +importlib-metadata==8.7.1 +iniconfig==2.3.0 +isodate==0.7.2 +itsdangerous==2.2.0 +jaraco-classes==3.4.0 +jaraco-context==6.1.0 +jaraco-functools==4.4.0 +jinja2==3.1.6 +jiter==0.13.0 +jmespath==1.1.0 +json-schema-to-pydantic==0.4.9 +jsonref==1.1.0 +jsonschema==4.26.0 +jsonschema-path==0.4.4 +jsonschema-specifications==2025.9.1 +keyring==25.7.0 +kiwisolver==1.4.9 +litellm==1.82.0 +litellm-enterprise==0.1.33 +litellm-proxy-extras==0.4.50 +markdown-it-py==4.0.0 +markdown2==2.5.5 +markupsafe==3.0.3 +matplotlib==3.10.8 +mcp==1.26.0 +mdurl==0.1.2 +more-itertools==10.8.0 +msal==1.35.0 +msal-extensions==1.3.1 +multidict==6.7.1 +multiprocess==0.70.18 +nicegui==3.8.0 +nodeenv==1.10.0 +numpy==2.4.2 +oauthlib==3.3.1 +openai==2.24.0 +openai-agents==0.10.3 +openapi-pydantic==0.5.1 +opentelemetry-api==1.39.1 +opentelemetry-exporter-otlp-proto-common==1.39.1 +opentelemetry-exporter-otlp-proto-grpc==1.39.1 +opentelemetry-exporter-otlp-proto-http==1.39.1 +opentelemetry-proto==1.39.1 +opentelemetry-sdk==1.39.1 +opentelemetry-semantic-conventions==0.60b1 +opentelemetry-semantic-conventions-ai==0.4.15 +orjson==3.11.7 +packaging==26.0 +pandas==3.0.1 +pathable==0.5.0 +patsy==1.0.2 +pillow==12.1.1 +platformdirs==4.9.2 +pluggy==1.6.0 +polars==1.38.1 +polars-runtime-32==1.38.1 +pre-commit==4.5.1 +propcache==0.4.1 +protobuf==6.33.5 +py-key-value-aio==0.4.4 +pyarrow==23.0.1 +pycparser==3.0 +pydantic==2.12.5 +pydantic-core==2.41.5 +pydantic-settings==2.13.1 +pygments==2.19.2 +pyjwt==2.11.0 +pynacl==1.6.2 +pyparsing==3.3.2 +pyperclip==1.11.0 +pyroscope-io==0.8.16 +pytest==9.0.2 +pytest-asyncio==1.3.0 +pytest-mock==3.15.1 +python-dateutil==2.9.0.post0 +python-discovery==1.1.0 +python-dotenv==1.2.2 +python-engineio==4.13.1 +python-multipart==0.0.22 +python-socketio==5.16.1 +pytz==2026.1.post1 +pyyaml==6.0.3 +redis==7.2.1 +referencing==0.37.0 +regex==2026.2.28 +requests==2.32.5 +rich==14.3.3 +rich-click==1.9.7 +rich-rst==1.3.2 +rpds-py==0.30.0 +rq==2.7.0 +ruff==0.15.4 +s3transfer==0.14.0 +scipy==1.17.1 +simple-websocket==1.1.0 +six==1.17.0 +smolagents==1.24.0 +sniffio==1.3.1 +soundfile==0.12.1 +sse-starlette==3.3.2 +starlette==0.52.1 +statsmodels==0.14.6 +tiktoken==0.12.0 +tokenizers==0.22.2 +tqdm==4.67.3 +types-requests==2.32.4.20260107 +typing-extensions==4.15.0 +typing-inspection==0.4.2 +tzlocal==5.3.1 +uncalled-for==0.2.0 +urllib3==2.6.3 +uvicorn==0.41.0 +uvloop==0.21.0 +virtualenv==21.1.0 +watchfiles==1.1.1 +websockets==15.0.1 +wikipedia-api==0.10.0 +wikipedia-mcp==2.0.0 +wsproto==1.3.2 +xxhash==3.6.0 +yarl==1.23.0 +zipp==3.23.0 diff --git a/labs/AgentStream/exgentic/misc/security/requirements/swebench/requirements.txt b/labs/AgentStream/exgentic/misc/security/requirements/swebench/requirements.txt new file mode 100644 index 00000000..c9d1ff68 --- /dev/null +++ b/labs/AgentStream/exgentic/misc/security/requirements/swebench/requirements.txt @@ -0,0 +1,194 @@ +aiofiles==25.1.0 +aiohappyeyeballs==2.6.1 +aiohttp==3.13.3 +aiosignal==1.4.0 +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.12.1 +apscheduler==3.11.2 +attrs==25.4.0 +azure-core==1.38.2 +azure-identity==1.25.2 +azure-storage-blob==12.28.0 +backoff==2.2.1 +beautifulsoup4==4.14.3 +bidict==0.23.1 +boto3==1.40.76 +botocore==1.40.76 +cbor2==5.8.0 +certifi==2026.2.25 +cffi==2.0.0 +cfgv==3.5.0 +chardet==6.0.0.post1 +charset-normalizer==3.4.4 +click==8.3.1 +cloudpickle==3.1.2 +codespell==2.4.1 +colorama==0.4.6 +contourpy==1.3.3 +croniter==6.0.0 +cryptography==46.0.5 +cycler==0.12.1 +datasets==4.6.1 +detect-secrets==1.5.0 +dill==0.4.0 +diskcache==5.6.3 +distlib==0.4.0 +distro==1.9.0 +dnspython==2.8.0 +docker==7.1.0 +docutils==0.22.4 +email-validator==2.3.0 +fastapi==0.135.1 +fastapi-sso==0.16.0 +fastcore==1.12.22 +fastuuid==0.14.0 +filelock==3.25.0 +fonttools==4.61.1 +frozenlist==1.8.0 +fsspec==2026.2.0 +ghapi==1.0.13 +gitdb==4.0.12 +gitpython==3.1.46 +googleapis-common-protos==1.72.0 +griffe==1.15.0 +grpcio==1.78.0 +grpclib==0.4.9 +gunicorn==23.0.0 +h11==0.16.0 +h2==4.3.0 +hf-xet==1.3.2 +hpack==4.1.0 +httpcore==1.0.9 +httptools==0.7.1 +httpx==0.28.1 +httpx-sse==0.4.3 +huggingface-hub==0.36.2 +hyperframe==6.1.0 +identify==2.6.17 +idna==3.11 +ifaddr==0.2.0 +importlib-metadata==8.7.1 +iniconfig==2.3.0 +isodate==0.7.2 +itsdangerous==2.2.0 +jinja2==3.1.6 +jiter==0.13.0 +jmespath==1.1.0 +json-schema-to-pydantic==0.4.9 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +kiwisolver==1.4.9 +linkify-it-py==2.1.0 +litellm==1.82.0 +litellm-enterprise==0.1.33 +litellm-proxy-extras==0.4.50 +markdown-it-py==4.0.0 +markdown2==2.5.5 +markupsafe==3.0.3 +matplotlib==3.10.8 +mcp==1.26.0 +mdit-py-plugins==0.5.0 +mdurl==0.1.2 +modal==1.3.4 +msal==1.35.0 +msal-extensions==1.3.1 +multidict==6.7.1 +multiprocess==0.70.18 +nicegui==3.8.0 +nodeenv==1.10.0 +numpy==2.4.2 +oauthlib==3.3.1 +openai==2.24.0 +openai-agents==0.10.3 +opentelemetry-api==1.39.1 +opentelemetry-exporter-otlp-proto-common==1.39.1 +opentelemetry-exporter-otlp-proto-grpc==1.39.1 +opentelemetry-exporter-otlp-proto-http==1.39.1 +opentelemetry-proto==1.39.1 +opentelemetry-sdk==1.39.1 +opentelemetry-semantic-conventions==0.60b1 +opentelemetry-semantic-conventions-ai==0.4.15 +orjson==3.11.7 +packaging==26.0 +pandas==3.0.1 +patsy==1.0.2 +pillow==12.1.1 +platformdirs==4.9.2 +pluggy==1.6.0 +polars==1.38.1 +polars-runtime-32==1.38.1 +pre-commit==4.5.1 +prompt-toolkit==3.0.52 +propcache==0.4.1 +protobuf==6.33.5 +pyarrow==23.0.1 +pycparser==3.0 +pydantic==2.12.5 +pydantic-core==2.41.5 +pydantic-settings==2.13.1 +pygments==2.19.2 +pyjwt==2.11.0 +pynacl==1.6.2 +pyparsing==3.3.2 +pyroscope-io==0.8.16 +pytest==9.0.2 +pytest-asyncio==1.3.0 +pytest-mock==3.15.1 +python-dateutil==2.9.0.post0 +python-discovery==1.1.0 +python-dotenv==1.2.2 +python-engineio==4.13.1 +python-multipart==0.0.22 +python-socketio==5.16.1 +pytz==2026.1.post1 +pyyaml==6.0.3 +redis==7.2.1 +referencing==0.37.0 +regex==2026.2.28 +requests==2.32.5 +rich==14.3.3 +rich-click==1.9.7 +rpds-py==0.30.0 +rq==2.7.0 +ruff==0.15.4 +s3transfer==0.14.0 +scipy==1.17.1 +shellingham==1.5.4 +simple-websocket==1.1.0 +six==1.17.0 +smmap==5.0.2 +smolagents==1.24.0 +sniffio==1.3.1 +soundfile==0.12.1 +soupsieve==2.8.3 +sse-starlette==3.3.2 +starlette==0.52.1 +statsmodels==0.14.6 +synchronicity==0.11.1 +tenacity==9.1.4 +textual==8.0.1 +tiktoken==0.12.0 +tokenizers==0.22.2 +toml==0.10.2 +tqdm==4.67.3 +typer==0.24.1 +types-certifi==2021.10.8.3 +types-requests==2.32.4.20260107 +types-toml==0.10.8.20240310 +typing-extensions==4.15.0 +typing-inspection==0.4.2 +tzlocal==5.3.1 +uc-micro-py==2.0.0 +unidiff==0.7.5 +urllib3==2.6.3 +uvicorn==0.41.0 +uvloop==0.21.0 +virtualenv==21.1.0 +watchfiles==1.1.1 +wcwidth==0.6.0 +websockets==15.0.1 +wsproto==1.3.2 +xxhash==3.6.0 +yarl==1.23.0 +zipp==3.23.0 diff --git a/labs/AgentStream/exgentic/misc/security/requirements/tau2/requirements.txt b/labs/AgentStream/exgentic/misc/security/requirements/tau2/requirements.txt new file mode 100644 index 00000000..2d8a2417 --- /dev/null +++ b/labs/AgentStream/exgentic/misc/security/requirements/tau2/requirements.txt @@ -0,0 +1,182 @@ +addict==2.4.0 +aiofiles==25.1.0 +aiohappyeyeballs==2.6.1 +aiohttp==3.13.3 +aiosignal==1.4.0 +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.12.1 +appdirs==1.4.4 +apscheduler==3.11.2 +attrs==25.4.0 +azure-core==1.38.2 +azure-identity==1.25.2 +azure-storage-blob==12.28.0 +backoff==2.2.1 +bidict==0.23.1 +boto3==1.40.76 +botocore==1.40.76 +certifi==2026.2.25 +cffi==2.0.0 +cfgv==3.5.0 +charset-normalizer==3.4.4 +click==8.3.1 +cloudpickle==3.1.2 +codespell==2.4.1 +colorama==0.4.6 +contourpy==1.3.3 +croniter==6.0.0 +cryptography==46.0.5 +cycler==0.12.1 +deepdiff==8.6.1 +detect-secrets==1.5.0 +diskcache==5.6.3 +distlib==0.4.0 +distro==1.9.0 +dnspython==2.8.0 +docstring-parser==0.17.0 +docutils==0.22.4 +email-validator==2.3.0 +fastapi==0.135.1 +fastapi-sso==0.16.0 +fastuuid==0.14.0 +filelock==3.25.0 +fonttools==4.61.1 +frozenlist==1.8.0 +fs==2.4.16 +fsspec==2026.2.0 +googleapis-common-protos==1.72.0 +griffe==1.15.0 +grpcio==1.78.0 +gunicorn==23.0.0 +h11==0.16.0 +hf-xet==1.3.2 +httpcore==1.0.9 +httptools==0.7.1 +httpx==0.28.1 +httpx-sse==0.4.3 +huggingface-hub==0.36.2 +identify==2.6.17 +idna==3.11 +ifaddr==0.2.0 +importlib-metadata==8.7.1 +iniconfig==2.3.0 +isodate==0.7.2 +itsdangerous==2.2.0 +jinja2==3.1.6 +jiter==0.13.0 +jmespath==1.1.0 +joblib==1.5.3 +json-schema-to-pydantic==0.4.9 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +kiwisolver==1.4.9 +langfuse==3.14.5 +litellm==1.82.0 +litellm-enterprise==0.1.33 +litellm-proxy-extras==0.4.50 +loguru==0.7.3 +markdown-it-py==4.0.0 +markdown2==2.5.5 +markupsafe==3.0.3 +matplotlib==3.10.8 +mcp==1.26.0 +mdurl==0.1.2 +msal==1.35.0 +msal-extensions==1.3.1 +multidict==6.7.1 +narwhals==2.17.0 +nicegui==3.8.0 +nodeenv==1.10.0 +numpy==2.4.2 +oauthlib==3.3.1 +openai==2.24.0 +openai-agents==0.10.3 +opentelemetry-api==1.39.1 +opentelemetry-exporter-otlp-proto-common==1.39.1 +opentelemetry-exporter-otlp-proto-grpc==1.39.1 +opentelemetry-exporter-otlp-proto-http==1.39.1 +opentelemetry-proto==1.39.1 +opentelemetry-sdk==1.39.1 +opentelemetry-semantic-conventions==0.60b1 +opentelemetry-semantic-conventions-ai==0.4.15 +orderly-set==5.5.0 +orjson==3.11.7 +packaging==25.0 +pandas==3.0.1 +patsy==1.0.2 +pillow==12.1.1 +platformdirs==4.9.2 +plotly==6.6.0 +pluggy==1.6.0 +polars==1.38.1 +polars-runtime-32==1.38.1 +pre-commit==4.5.1 +propcache==0.4.1 +protobuf==6.33.5 +psutil==7.2.2 +pycparser==3.0 +pydantic==2.12.5 +pydantic-argparse==0.10.0 +pydantic-core==2.41.5 +pydantic-settings==2.13.1 +pygments==2.19.2 +pyjwt==2.11.0 +pynacl==1.6.2 +pyparsing==3.3.2 +pyroscope-io==0.8.16 +pytest==9.0.2 +pytest-asyncio==1.3.0 +pytest-mock==3.15.1 +python-dateutil==2.9.0.post0 +python-discovery==1.1.0 +python-dotenv==1.2.2 +python-engineio==4.13.1 +python-multipart==0.0.22 +python-socketio==5.16.1 +pytz==2026.1.post1 +pyyaml==6.0.3 +redis==7.2.1 +referencing==0.37.0 +regex==2026.2.28 +requests==2.32.5 +rich==14.3.3 +rich-click==1.9.7 +rpds-py==0.30.0 +rq==2.7.0 +ruff==0.15.4 +s3transfer==0.14.0 +scikit-learn==1.8.0 +scipy==1.17.1 +seaborn==0.13.2 +setuptools==82.0.0 +simple-websocket==1.1.0 +six==1.17.0 +smolagents==1.24.0 +sniffio==1.3.1 +soundfile==0.12.1 +sse-starlette==3.3.2 +starlette==0.52.1 +statsmodels==0.14.6 +tabulate==0.9.0 +tenacity==9.1.4 +threadpoolctl==3.6.0 +tiktoken==0.12.0 +tokenizers==0.22.2 +toml==0.10.2 +tqdm==4.67.3 +types-requests==2.32.4.20260107 +typing-extensions==4.15.0 +typing-inspection==0.4.2 +tzlocal==5.3.1 +urllib3==2.6.3 +uvicorn==0.41.0 +uvloop==0.21.0 +virtualenv==21.1.0 +watchdog==6.0.0 +watchfiles==1.1.1 +websockets==15.0.1 +wrapt==1.17.3 +wsproto==1.3.2 +yarl==1.23.0 +zipp==3.23.0 diff --git a/labs/AgentStream/exgentic/misc/security/setup_environments.sh b/labs/AgentStream/exgentic/misc/security/setup_environments.sh new file mode 100644 index 00000000..d0990814 --- /dev/null +++ b/labs/AgentStream/exgentic/misc/security/setup_environments.sh @@ -0,0 +1,303 @@ +#!/usr/bin/env bash +# setup_environments.sh - Create multiple UV virtual environments for Exgentic +# +# Usage: ./setup_environments.sh [benchmark1 benchmark2 ...] +# +# This script creates: +# 1. A 'core' environment with all optional dependencies +# 2. Benchmark-specific environments for specified benchmarks (or all if none specified) +# 3. Generates requirements.txt files for each environment +# +# Examples: +# ./setup_environments.sh # Create all environments +# ./setup_environments.sh gsm8k hotpotqa # Create only gsm8k and hotpotqa +# ./setup_environments.sh core # Create only core environment + +set -euo pipefail + +# Tracking arrays for summary report +SETUP_PASSED=() +SETUP_FAILED=() + +# Get the project root (two levels up from this script in misc/security/) +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +VENV_DIR="${PROJECT_ROOT}/.venvs" +REQUIREMENTS_DIR="${SCRIPT_DIR}/requirements" + +# Colors for output +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Check if uv is installed +if ! command -v uv >/dev/null 2>&1; then + log_error "uv is not installed. Install it with: pip install uv" + exit 1 +fi + +# Parse command line arguments +REQUESTED_BENCHMARKS=() +if [ $# -gt 0 ]; then + REQUESTED_BENCHMARKS=("$@") +fi +ALL_BENCHMARKS=("gsm8k" "hotpotqa" "appworld" "browsecompplus" "swebench" "tau2") + +# Determine which benchmarks to create +if [ ${#REQUESTED_BENCHMARKS[@]} -eq 0 ]; then + # No arguments provided, create all + CREATE_CORE=true + BENCHMARKS_TO_CREATE=("${ALL_BENCHMARKS[@]}") + log_info "No benchmarks specified, creating all environments" +elif [ "${REQUESTED_BENCHMARKS[0]}" = "core" ] && [ ${#REQUESTED_BENCHMARKS[@]} -eq 1 ]; then + # Only 'core' requested + CREATE_CORE=true + BENCHMARKS_TO_CREATE=() + log_info "Creating only core environment" +else + # Specific benchmarks requested + CREATE_CORE=false + BENCHMARKS_TO_CREATE=() + + # Check if 'core' is in the list + for arg in "${REQUESTED_BENCHMARKS[@]}"; do + if [ "$arg" = "core" ]; then + CREATE_CORE=true + else + # Validate benchmark name + if [[ " ${ALL_BENCHMARKS[@]} " =~ " ${arg} " ]]; then + BENCHMARKS_TO_CREATE+=("$arg") + else + log_warning "Unknown benchmark: $arg (skipping)" + fi + fi + done + + if [ "$CREATE_CORE" = true ]; then + log_info "Creating core environment and benchmarks: ${BENCHMARKS_TO_CREATE[*]}" + else + log_info "Creating benchmark environments: ${BENCHMARKS_TO_CREATE[*]}" + fi +fi + +log_info "Starting environment setup..." +log_info "Project root: ${PROJECT_ROOT}" +log_info "Virtual environments will be created in: ${VENV_DIR}" + +mkdir -p "${VENV_DIR}" + +# 1. Create 'core' environment with all optional dependencies +if [ "$CREATE_CORE" = true ]; then + log_info "Creating 'core' environment with all optional dependencies..." + + uv venv "${VENV_DIR}/core" --python 3.11 + + # Activate and install + source "${VENV_DIR}/core/bin/activate" + + # Install project with all optional dependencies + log_info "Installing base project with all optional dependencies..." + uv pip install -e "${PROJECT_ROOT}[smolagents,openaimacp,otel,cli,dev]" + + # Generate requirements.txt for core environment + log_info "Generating requirements.txt for core environment..." + mkdir -p "${REQUIREMENTS_DIR}/core" + uv pip freeze | grep -v "^-e " | grep -v " @ file://" | grep -v "github.ibm.com" | grep -v " @ git+https://" | grep -v " @ git+ssh://" > "${REQUIREMENTS_DIR}/core/requirements.txt" + core_pkg_count=$(wc -l < "${REQUIREMENTS_DIR}/core/requirements.txt" | tr -d ' ') + log_success "Saved ${core_pkg_count} packages to ${REQUIREMENTS_DIR}/core/requirements.txt" + + deactivate + log_success "Core environment created at ${VENV_DIR}/core" +else + log_info "Skipping core environment creation" +fi + +# 2. Create benchmark-specific environments +if [ ${#BENCHMARKS_TO_CREATE[@]} -gt 0 ]; then + BENCHMARKS=("${BENCHMARKS_TO_CREATE[@]}") +else + BENCHMARKS=() +fi + +if [ ${#BENCHMARKS[@]} -gt 0 ]; then +for benchmark in "${BENCHMARKS[@]}"; do + log_info "Creating '${benchmark}' environment..." + + SETUP_SCRIPT="${PROJECT_ROOT}/src/exgentic/benchmarks/${benchmark}/setup.sh" + + if [ ! -f "${SETUP_SCRIPT}" ]; then + log_warning "Setup script not found: ${SETUP_SCRIPT}, skipping..." + continue + fi + + # Create virtual environment + uv venv "${VENV_DIR}/${benchmark}" --python 3.11 + + # Activate environment + source "${VENV_DIR}/${benchmark}/bin/activate" + + # Install base project with all optional dependencies + log_info "Installing base project with all optional dependencies for ${benchmark}..." + uv pip install -e "${PROJECT_ROOT}[smolagents,openaimacp,otel,cli,dev]" + + # Run benchmark-specific setup script + log_info "Running setup script for ${benchmark}..." + + # Change to project root before running setup script + # (some scripts expect to be run from project root) + cd "${PROJECT_ROOT}" + + SETUP_OK=true + if bash "${SETUP_SCRIPT}"; then + log_success "${benchmark} setup completed successfully" + SETUP_PASSED+=("${benchmark}") + else + log_warning "${benchmark} setup script encountered issues (exit code: $?)" + SETUP_FAILED+=("${benchmark}") + SETUP_OK=false + fi + + # Generate requirements.txt only when setup succeeded + if [ "${SETUP_OK}" = true ]; then + log_info "Generating requirements.txt for ${benchmark} environment..." + mkdir -p "${REQUIREMENTS_DIR}/${benchmark}" + uv pip freeze | grep -v "^-e " | grep -v " @ file://" | grep -v "github.ibm.com" | grep -v " @ git+https://" | grep -v " @ git+ssh://" > "${REQUIREMENTS_DIR}/${benchmark}/requirements.txt" + bench_pkg_count=$(wc -l < "${REQUIREMENTS_DIR}/${benchmark}/requirements.txt" | tr -d ' ') + log_success "Saved ${bench_pkg_count} packages to ${REQUIREMENTS_DIR}/${benchmark}/requirements.txt" + else + log_warning "Skipping requirements.txt generation for ${benchmark} due to setup failure" + fi + + deactivate + log_success "${benchmark} environment created at ${VENV_DIR}/${benchmark}" +done +fi + +# Generate README for requirements +log_info "Generating requirements README..." +cat > "${REQUIREMENTS_DIR}/README.md" << 'EOF' +# Requirements Files + +This directory contains frozen requirements for each virtual environment. + +## Directories + +Each environment has its own directory with a `requirements.txt` file: + +- `core/requirements.txt` - Core environment with all optional dependencies +- `gsm8k/requirements.txt` - GSM8K benchmark environment +- `hotpotqa/requirements.txt` - HotpotQA benchmark environment +- `appworld/requirements.txt` - AppWorld benchmark environment +- `browsecompplus/requirements.txt` - BrowseComp+ benchmark environment +- `swebench/requirements.txt` - SWE-bench benchmark environment +- `tau2/requirements.txt` - TAU-2 benchmark environment + +## Usage + +To recreate an environment from a requirements file: + +```bash +# Create a new virtual environment +uv venv .venv --python 3.11 + +# Activate it +source .venv/bin/activate + +# Install from requirements +uv pip install -r misc/security/requirements/core/requirements.txt +``` + +## Notes + +- These files are generated automatically by `misc/security/setup_environments.sh` +- They represent the exact package versions installed in each environment +- Only PyPI packages are included (no git or local installs) +- Regenerate by re-running the setup script + +## Generation Date + +EOF + +log_success "Requirements README created at ${REQUIREMENTS_DIR}/README.md" + +# Summary +echo "" +log_success "Environment setup completed!" +echo "" +echo "Created environments:" +if [ "$CREATE_CORE" = true ] && [ -d "${VENV_DIR}/core" ]; then + echo " ${VENV_DIR}/core - Core environment with all optional dependencies" + if [ -f "${REQUIREMENTS_DIR}/core/requirements.txt" ]; then + pkg_count=$(wc -l < "${REQUIREMENTS_DIR}/core/requirements.txt" | tr -d ' ') + echo " Requirements: ${REQUIREMENTS_DIR}/core/requirements.txt (${pkg_count} packages)" + fi +fi +if [ ${#BENCHMARKS[@]} -gt 0 ]; then +for benchmark in "${BENCHMARKS[@]}"; do + if [ -d "${VENV_DIR}/${benchmark}" ]; then + echo " ${VENV_DIR}/${benchmark} - ${benchmark} benchmark environment" + if [ -f "${REQUIREMENTS_DIR}/${benchmark}/requirements.txt" ]; then + pkg_count=$(wc -l < "${REQUIREMENTS_DIR}/${benchmark}/requirements.txt" | tr -d ' ') + echo " Requirements: ${REQUIREMENTS_DIR}/${benchmark}/requirements.txt (${pkg_count} packages)" + fi + fi +done +fi +echo "" +echo "To activate an environment, use:" +if [ "$CREATE_CORE" = true ]; then + echo " source ${VENV_DIR}/core/bin/activate" +fi +if [ ${#BENCHMARKS[@]} -gt 0 ]; then +for benchmark in "${BENCHMARKS[@]}"; do + if [ -d "${VENV_DIR}/${benchmark}" ]; then + echo " source ${VENV_DIR}/${benchmark}/bin/activate" + fi +done +fi +echo "" +echo "Note: Some benchmarks may require additional prerequisites:" +echo " - appworld, swebench: Git LFS" +echo " - browsecompplus: Java 21+, SSH access to IBM GitHub" +echo "" +echo "Usage examples:" +echo " ./misc/security/setup_environments.sh # Create all environments" +echo " ./misc/security/setup_environments.sh gsm8k hotpotqa # Create specific benchmarks" +echo " ./misc/security/setup_environments.sh core # Create only core environment" + +# Setup script pass/fail summary +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " Setup Script Results" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +if [ ${#SETUP_PASSED[@]} -gt 0 ]; then + for b in "${SETUP_PASSED[@]}"; do + echo -e " ${GREEN}✔ PASSED${NC} ${b}" + done +else + echo " (no benchmark setup scripts ran)" +fi +if [ ${#SETUP_FAILED[@]} -gt 0 ]; then + for b in "${SETUP_FAILED[@]}"; do + echo -e " ${RED}✘ FAILED${NC} ${b}" + done +fi +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" diff --git a/labs/AgentStream/exgentic/misc/skills/add-agent/SKILL.md b/labs/AgentStream/exgentic/misc/skills/add-agent/SKILL.md new file mode 100644 index 00000000..047d8a16 --- /dev/null +++ b/labs/AgentStream/exgentic/misc/skills/add-agent/SKILL.md @@ -0,0 +1,68 @@ +--- +name: add-agent +description: Use when adding or updating an agent adapter in the Exgentic repository. Follow the repository agent principles, separate lightweight config from heavy execution logic, isolate third-party dependencies behind lazy imports, adapt to any benchmark contract without requiring benchmark changes, and validate the adapter with representative smoke tests before finishing. +--- + +# Add Agent + +Use this skill when working on agent adapters in the Exgentic repository. + +## First read + +Start with: +- `docs/adding-agents.md` +- `src/exgentic/core/agent.py` +- `src/exgentic/core/agent_instance.py` +- `src/exgentic/interfaces/registry.py` + +Then inspect the most relevant existing adapters: +- `src/exgentic/agents/litellm_tool_calling/litellm_tool_calling_agent.py` + `instance.py` (split pattern: heavy deps in separate file) +- `src/exgentic/agents/cli/claude/agent.py` (light pattern: everything in one file) + +## Workflow + +1. Decide whether to split files. + If the agent depends on heavy third-party libraries (litellm, smolagents, openai SDK, etc.), put the Agent in one file and the AgentInstance in `instance.py`. If deps are light, keep both in a single file. + +2. Implement the Agent class first. + Subclass `Agent`. Declare `display_name` and `slug_name` as `ClassVar[str]`. Add user-facing config fields. Implement `_get_instance_class()` with a lazy import. Implement `_get_instance_kwargs()` to translate config and the benchmark contract into instance constructor kwargs. + +3. Implement the AgentInstance class. + Subclass `AgentInstance`. Accept `session_id` plus the kwargs from `_get_instance_kwargs()`. Call `super().__init__(session_id)`. Implement `react()` as the core decision loop and `close()` for cleanup. Optionally override `start()` and `get_cost()`. + +4. Add `requirements.txt` for agent-specific dependencies. + List only packages not already in the base exgentic install. Place the file in the agent's package directory; `RunnerMixin` discovers it automatically. + +5. Add `setup.sh` if non-pip setup is needed. + Place it next to the agent module; `RunnerMixin` discovers it automatically. + +6. Register the agent in `src/exgentic/interfaces/registry.py`. + Add a `RegistryEntry` to the `AGENTS` dict. Ensure `slug_name` and `display_name` match the class exactly. + +7. Validate the adapter as an agent, not just as code. + Check registry loading, dependency isolation, at least one end-to-end benchmark run, cost reporting, and cleanup. + +## Non-negotiable rules + +- The Agent file must be importable without installing agent-specific packages. +- `_get_instance_class()` must use a lazy import to isolate heavy deps. +- `_get_instance_kwargs()` must faithfully pass the benchmark contract (task, context, actions, session_id) through to the instance. +- The instance constructor must call `super().__init__(session_id)`. +- `react()` must return `None` when the agent decides it is done. +- `close()` must not raise exceptions. +- `slug_name` and `display_name` in the registry entry must exactly match the class values. +- The agent must adapt to the benchmark, never the other way around. + +## Validation + +Before finishing, run at least: +- `python -m py_compile` on changed agent files +- `pre-commit run --files ...` +- `git diff --check` + +Also confirm: +- `load_agent("slug_name")` succeeds from a Python shell +- The Agent file imports cleanly without agent-specific packages installed +- At least one benchmark runs end to end with the new agent +- `close()` completes without error +- `get_cost()` returns a valid report diff --git a/labs/AgentStream/exgentic/misc/skills/add-benchmark/SKILL.md b/labs/AgentStream/exgentic/misc/skills/add-benchmark/SKILL.md new file mode 100644 index 00000000..3e3da2f1 --- /dev/null +++ b/labs/AgentStream/exgentic/misc/skills/add-benchmark/SKILL.md @@ -0,0 +1,62 @@ +--- +name: add-benchmark +description: Use when adding or updating a benchmark adapter in the Exgentic repository. Follow the repository benchmark principles, keep the benchmark contract protocol-agnostic, prefer the thinnest possible wrapper that makes the benchmark accessible to any Exgentic agent, reuse external harness assets and scoring where possible, and validate the adapter with representative smoke tests before finishing. +--- + +# Add Benchmark + +Use this skill when working on benchmark adapters in the Exgentic repository. + +## First read + +Start with: +- `docs/adding-benchmarks.md` +- `src/exgentic/core/benchmark.py` +- `src/exgentic/interfaces/registry.py` + +Then inspect the most relevant existing adapters: +- `src/exgentic/benchmarks/tau2/tau2_benchmark.py` +- `src/exgentic/benchmarks/bfcl/bfcl_benchmark.py` + +## Workflow + +1. Define the benchmark contract before writing code. + Decide the real `task`, the agent-relevant `context`, the semantic `actions`, the finish condition, and the scoring boundary. + +2. Keep the agent-facing contract protocol-agnostic. + Do not define the benchmark in terms of one provider's chat or tool-calling format. + +3. Prefer the thinnest possible wrapper. + Make the benchmark accessible to any Exgentic agent with the minimum translation surface necessary. Do not add extra abstraction, copied logic, or runtime machinery unless it is needed to preserve benchmark meaning. + +4. Decide the source-of-truth boundary. + Reuse external benchmark assets, setup, and scoring where possible, but do not let an external harness dictate the wrong runtime contract for Exgentic. + +5. Implement runtime, setup, and registration separately. + Prefer a benchmark module, a `setup.sh`, and a registry entry with clear responsibilities. + +6. Validate the adapter as a benchmark, not just as code. + Check task listing, subset listing, happy-path scoring, failure-path scoring, and error semantics. + +## Non-negotiable rules + +- `task` must be the actual task. +- `context` must include only what the agent should know. +- subset names and internal metadata stay out of `context`. +- Prefer the thinnest wrapper that preserves the benchmark's meaning. +- Actions should describe semantic operations, not protocol artifacts. +- Use `finish` only when it is part of the benchmark contract. +- If outputs are not real execution results, say so plainly in the benchmark contract. +- Keep success, unsuccessful completion, unfinished runs, and errors distinct. + +## Validation + +Before finishing, run at least: +- `python -m py_compile` on changed benchmark files +- `pre-commit run --files ...` +- `git diff --check` + +Also run benchmark-specific smoke tests that prove: +- one passing case works +- one failing case is represented correctly +- one real error is surfaced as an error diff --git a/labs/AgentStream/exgentic/misc/utils/.secrets.baseline b/labs/AgentStream/exgentic/misc/utils/.secrets.baseline new file mode 100644 index 00000000..3e5ec796 --- /dev/null +++ b/labs/AgentStream/exgentic/misc/utils/.secrets.baseline @@ -0,0 +1,150 @@ +{ + "version": "1.5.0", + "plugins_used": [ + { + "name": "ArtifactoryDetector" + }, + { + "name": "AWSKeyDetector" + }, + { + "name": "AzureStorageKeyDetector" + }, + { + "name": "Base64HighEntropyString", + "limit": 4.5 + }, + { + "name": "BasicAuthDetector" + }, + { + "name": "CloudantDetector" + }, + { + "name": "DiscordBotTokenDetector" + }, + { + "name": "GitHubTokenDetector" + }, + { + "name": "GitLabTokenDetector" + }, + { + "name": "HexHighEntropyString", + "limit": 3.0 + }, + { + "name": "IbmCloudIamDetector" + }, + { + "name": "IbmCosHmacDetector" + }, + { + "name": "IPPublicDetector" + }, + { + "name": "JwtTokenDetector" + }, + { + "name": "KeywordDetector", + "keyword_exclude": "" + }, + { + "name": "MailchimpDetector" + }, + { + "name": "NpmDetector" + }, + { + "name": "OpenAIDetector" + }, + { + "name": "PrivateKeyDetector" + }, + { + "name": "PypiTokenDetector" + }, + { + "name": "SendGridDetector" + }, + { + "name": "SlackDetector" + }, + { + "name": "SoftlayerDetector" + }, + { + "name": "SquareOAuthDetector" + }, + { + "name": "StripeDetector" + }, + { + "name": "TelegramBotTokenDetector" + }, + { + "name": "TwilioKeyDetector" + } + ], + "filters_used": [ + { + "path": "detect_secrets.filters.allowlist.is_line_allowlisted" + }, + { + "path": "detect_secrets.filters.common.is_baseline_file", + "filename": "labs/AgentStream/exgentic/misc/utils/.secrets.baseline" + }, + { + "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", + "min_level": 2 + }, + { + "path": "detect_secrets.filters.heuristic.is_indirect_reference" + }, + { + "path": "detect_secrets.filters.heuristic.is_likely_id_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_lock_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_potential_uuid" + }, + { + "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign" + }, + { + "path": "detect_secrets.filters.heuristic.is_sequential_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_swagger_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_templated_secret" + } + ], + "results": { + "labs/AgentStream/exgentic/tests/benchmarks/recordings/appworld/trajectory.jsonl": [ + { + "type": "JSON Web Token", + "filename": "labs/AgentStream/exgentic/tests/benchmarks/recordings/appworld/trajectory.jsonl", + "hashed_secret": "0c677ddce015761585645772702fd153efbb08d9", + "is_verified": false, + "line_number": 7 + } + ], + "labs/AgentStream/exgentic/tests/benchmarks/recordings/swebench/results.json": [ + { + "type": "Secret Keyword", + "filename": "labs/AgentStream/exgentic/tests/benchmarks/recordings/swebench/results.json", + "hashed_secret": "d4e0e04792fd434b5dc9c4155c178f66edcf4ed3", + "is_verified": false, + "line_number": 46 + } + ] + }, + "generated_at": "2026-03-17T11:41:59Z" +} diff --git a/labs/AgentStream/exgentic/misc/utils/enforce_dependency_caps.py b/labs/AgentStream/exgentic/misc/utils/enforce_dependency_caps.py new file mode 100644 index 00000000..e2e07a01 --- /dev/null +++ b/labs/AgentStream/exgentic/misc/utils/enforce_dependency_caps.py @@ -0,0 +1,124 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +#!/usr/bin/env python3 +"""Enforce that all direct dependencies in pyproject.toml have upper version bounds. + +This script prevents supply chain attacks by ensuring no dependency can auto-upgrade +to an arbitrary future version. All dependencies must be capped at the next major +version (e.g., >=1.0.0,<2). + +Exit codes: + 0: All dependencies have upper bounds + 1: One or more dependencies lack upper bounds +""" + +import re +import sys +from pathlib import Path + + +def _extract_dependency_lines(content: str) -> list[str]: + """Extract lines that belong to dependency sections in pyproject.toml. + + Scopes extraction to [project.dependencies] and + [project.optional-dependencies.*] sections only, so that version-like + strings in other sections (e.g. build-system.requires) are ignored. + """ + lines: list[str] = [] + in_dep_section = False + in_dep_array = False + + for line in content.splitlines(): + stripped = line.strip() + + # Detect section headers + if stripped.startswith("["): + in_dep_section = stripped in ("[project]",) or stripped.startswith("[project.optional-dependencies") + in_dep_array = False + continue + + if not in_dep_section: + continue + + # Inside a relevant section, look for dependency array starts + if "dependencies" in stripped and "=" in stripped and "[" in stripped: + in_dep_array = True + continue + # Also handle bare list continuation under optional-dependencies groups + if stripped.startswith('"') and in_dep_section and not in_dep_array: + # We're likely in an optional-dep group list + in_dep_array = True + + if in_dep_array: + if stripped == "]": + in_dep_array = False + continue + lines.append(line) + + return lines + + +def check_dependency_caps(pyproject_path: Path) -> list[str]: + """Check all dependencies in pyproject.toml for upper version bounds. + + Args: + pyproject_path: Path to pyproject.toml file + + Returns: + List of dependency lines that lack upper bounds (empty if all are capped) + """ + content = pyproject_path.read_text() + uncapped = [] + + # Only check lines inside dependency sections + dep_lines = _extract_dependency_lines(content) + + # Pattern to match dependency specifications (supports extras like [extra]) + # Matches: "package>=1.0.0" or "package[extra]>=1.0.0,!=1.2.3" but not "package>=1.0.0,<2" + dep_pattern = re.compile( + r'^\s*"([a-zA-Z0-9_-]+(?:\[[a-zA-Z0-9_,\s-]+\])?)([><=!,.\d\s]+)"', + ) + + for line in dep_lines: + match = dep_pattern.match(line) + if match: + full_line = match.group(0).strip() + version_spec = match.group(2) + + # Check if there's an upper bound (< or <=) + if "<" not in version_spec: + uncapped.append(full_line) + + return uncapped + + +def main() -> int: + """Main entry point.""" + project_root = Path(__file__).resolve().parents[2] + pyproject_path = project_root / "pyproject.toml" + + if not pyproject_path.exists(): + print("Error: pyproject.toml not found", file=sys.stderr) + return 1 + + uncapped = check_dependency_caps(pyproject_path) + + if uncapped: + print("ERROR: Dependencies without upper version bounds found:", file=sys.stderr) + print(file=sys.stderr) + for dep in uncapped: + print(f" {dep}", file=sys.stderr) + print(file=sys.stderr) + print( + "All dependencies must have upper bounds (e.g., >=1.0.0,<2) to limit supply chain attack exposure.", + file=sys.stderr, + ) + return 1 + + print("All dependencies have upper version bounds") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/labs/AgentStream/exgentic/misc/utils/enforce_library_imports.py b/labs/AgentStream/exgentic/misc/utils/enforce_library_imports.py new file mode 100644 index 00000000..6f1058d9 --- /dev/null +++ b/labs/AgentStream/exgentic/misc/utils/enforce_library_imports.py @@ -0,0 +1,32 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import logging +import sys + +logging.basicConfig(level=logging.INFO, format="%(message)s") +logger = logging.getLogger(__name__) + + +def check_imports(file_path): + success = True + with open(file_path, encoding="utf-8") as file: + for lineno, line in enumerate(file, start=1): + if "from src import" in line or "from src." in line or "import src" in line: + logger.error(f"Non library import: {file_path}:{lineno}: {line.strip()[:30]}...") + success = False + return success + + +def main(): + success = True + for file_path in sys.argv[1:]: + if not check_imports(file_path): + success = False + + if not success: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/labs/AgentStream/exgentic/misc/utils/enforce_relative_imports.py b/labs/AgentStream/exgentic/misc/utils/enforce_relative_imports.py new file mode 100644 index 00000000..8531541e --- /dev/null +++ b/labs/AgentStream/exgentic/misc/utils/enforce_relative_imports.py @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import logging +import sys + +logging.basicConfig(level=logging.INFO, format="%(message)s") +logger = logging.getLogger(__name__) + +library_name = "exgentic" + + +def check_imports(file_path): + success = True + with open(file_path, encoding="utf-8") as file: + for lineno, line in enumerate(file, start=1): + if ( + f"from {library_name} import" in line + or f"from {library_name}." in line + or f"import {library_name}" in line + or "from src import" in line + or "from src." in line + or "import src" in line + ): + logger.error(f"Non relative import: {file_path}:{lineno}: {line.strip()[:30]}...") + success = False + return success + + +def main(): + success = True + for file_path in sys.argv[1:]: + if not check_imports(file_path): + success = False + + if not success: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/labs/AgentStream/exgentic/misc/utils/enforce_spdx_header.py b/labs/AgentStream/exgentic/misc/utils/enforce_spdx_header.py new file mode 100644 index 00000000..51378aeb --- /dev/null +++ b/labs/AgentStream/exgentic/misc/utils/enforce_spdx_header.py @@ -0,0 +1,71 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +#!/usr/bin/env python3 +"""Ensure SPDX+copyright header exists at top of Python files.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +HEADER_LINES = [ + "# SPDX-License-Identifier: Apache-2.0", + "# Copyright (C) 2026, The AgentStream organization and its contributors.", +] +HEADER_TEXT = "\n".join(HEADER_LINES) + "\n\n" + +COPYRIGHT_RE = re.compile( + r"^# Copyright \(C\) \d{4}, The (?:Exgentic|AgentStream) organization and its contributors\.$" +) +APACHE_LICENSE_MARKER = 'Licensed under the Apache License, Version 2.0 (the "License")' + +SKIP_DIRS = { + ".git", + ".venv", + ".pytest_cache", + ".ruff_cache", + "__pycache__", +} + + +def should_skip(path: Path) -> bool: + return bool(set(path.parts) & SKIP_DIRS) + + +def update_file(path: Path) -> bool: + raw = path.read_bytes() + original = raw.decode("utf-8", errors="surrogateescape") + if not original: + return False + + lines = original.splitlines() + if len(lines) >= 2 and lines[0] == HEADER_LINES[0] and COPYRIGHT_RE.match(lines[1]): + return False + + # Preserve complete Apache-2.0 notices carried by third-party source files. + if APACHE_LICENSE_MARKER in "\n".join(lines[:20]): + return False + + updated = HEADER_TEXT + original + path.write_text(updated, encoding="utf-8", errors="surrogateescape") + return True + + +def main(argv: list[str]) -> int: + changed = False + for filename in argv: + path = Path(filename) + if not path.is_file() or should_skip(path): + continue + if update_file(path): + changed = True + if changed: + print("SPDX headers updated. Re-run pre-commit.") + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/labs/AgentStream/exgentic/pyproject.toml b/labs/AgentStream/exgentic/pyproject.toml new file mode 100644 index 00000000..af133ae1 --- /dev/null +++ b/labs/AgentStream/exgentic/pyproject.toml @@ -0,0 +1,104 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "exgentic" +# Bundled Exgentic snapshot used by AgentStream; not published independently from Sico. +version = "0.0.0+agentstream" +description = "Exgentic - General agent evaluation" +authors = [{name = "Exgentic Team"}] +license = {text = "Apache-2.0"} +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "typing-extensions>=4,<5", + "pydantic-settings>=2,<3", + "nicegui>=3,<4", + "cloudpickle>=3,<4", + "diskcache>=5,<6", + "filelock>=3,<4", + "click>=8.1.7,<9", + "json-schema-to-pydantic>=0.4,<1", + "litellm>=1.65.0,!=1.82.7,!=1.82.8,<2", + "mcp>=1.24,<2", + "pydantic>=2.9.2,<3", + "python-dotenv>=1,<2", + "rich>=13,<14", + "rich-click>=1,<2", +] + +[project.scripts] +exgentic = "exgentic.interfaces.cli.main:main" + +[project.optional-dependencies] +# Observability +otel = [ + "opentelemetry-api>=1,<2", + "opentelemetry-sdk>=1,<2", + "opentelemetry-exporter-otlp-proto-http>=1,<2", + "opentelemetry-exporter-otlp-proto-grpc>=1,<2", + "opentelemetry-semantic-conventions-ai>=0.4.0,<1", +] +analysis = [ + "matplotlib>=3,<4", + "numpy>=2,<3", + "pandas>=3,<4", + "scipy>=1,<2", + "statsmodels>=0.14,<1", +] +amem = [ + "sentence-transformers>=3,<5", + "scikit-learn>=1,<2", +] + +# Development +dev = [ + "pytest>=7.0.0,<10", + "pytest-asyncio>=0.21.0,<2", + "pytest-mock>=3.0.0,<4", + "pre-commit>=3.0.0,<5", + "ruff>=0.1.0,<1", + "codespell>=2.0.0,<3", + "detect-secrets>=1.0.0,<2", +] + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel] +packages = ["src/exgentic"] +include = [ + "src/exgentic/benchmarks/**/requirements.txt", + "src/exgentic/benchmarks/**/setup.sh", + "src/exgentic/benchmarks/**/system-deps.txt", + "src/exgentic/agents/**/requirements.txt", + "src/exgentic/agents/**/setup.sh", + "src/exgentic/agents/**/system-deps.txt", +] + +[tool.hatch.build.targets.sdist] +include = [ + "/src", + "/tests", + "/docs", + "/examples", +] + +[tool.uv] +override-dependencies = [ + "rich>=13.9.4,<14", +] + +[tool.codespell] +skip = "tests/benchmarks/recordings/*,uv.lock" +ignore-words-list = "requestor" + +[tool.pytest.ini_options] +asyncio_mode = "strict" +pythonpath = ["."] + +[dependency-groups] +dev = [ + "pytest>=9.0.1,<10", +] diff --git a/labs/AgentStream/exgentic/ruff.toml b/labs/AgentStream/exgentic/ruff.toml new file mode 100644 index 00000000..aba4f270 --- /dev/null +++ b/labs/AgentStream/exgentic/ruff.toml @@ -0,0 +1,78 @@ +# Ruff configuration for Exgentic project + +# Set line length +line-length = 120 + +# Exclude common directories +extend-exclude = [ + ".venv", + ".venvs", + "venv", + ".git", + "__pycache__", + ".pytest_cache", + ".exgentic", +] + +[lint] +# Enable specific rule sets +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "D", # pydocstyle + "RUF", # Ruff-specific rules + "RET", # flake8-return + "C90", # mccabe complexity + "N", # pep8-naming + "G", # flake8-logging-format +] + +# Ignore specific rules +ignore = [ + "TID252", # Relative imports from parent modules - conflicts with our enforce-relative-imports hook + "C901", # Function is too complex - will be addressed separately + "T201", # Print statements - will be addressed separately + "D100", # Missing docstring in public module + "D101", # Missing docstring in public class + "D102", # Missing docstring in public method + "D103", # Missing docstring in public function + "D104", # Missing docstring in public package + "D105", # Missing docstring in magic method + "D107", # Missing docstring in __init__ + "G004", # Logging statement uses f-string + "UP007", # Use `X | Y` for type annotations + "RET504", # Unnecessary assignment to ... before return +] + +# Allow autofix for all enabled rules +fixable = ["ALL"] +unfixable = [] + +[lint.per-file-ignores] +# Allow print statements in test files and scripts +"tests/**/*.py" = ["T201"] +"examples/**/*.py" = ["T201"] +"misc/**/*.py" = ["T201"] + +[lint.pydocstyle] +# Use Google-style docstrings +convention = "google" + +[lint.mccabe] +# Set maximum complexity +max-complexity = 15 + +[format] +# Use double quotes for strings +quote-style = "double" + +# Indent with 4 spaces +indent-style = "space" + +# Use Unix line endings +line-ending = "lf" diff --git a/labs/AgentStream/exgentic/scripts/a_mem/run_experiment.py b/labs/AgentStream/exgentic/scripts/a_mem/run_experiment.py new file mode 100644 index 00000000..4b319465 --- /dev/null +++ b/labs/AgentStream/exgentic/scripts/a_mem/run_experiment.py @@ -0,0 +1,416 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +import argparse +import json +import os +import sys +from collections import defaultdict +from datetime import datetime +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src")) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "utils")) + +os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + +from exgentic.interfaces.lib.api import evaluate +from exgentic.interfaces.registry import load_agent, load_benchmark +from exgentic.agents.a_mem.memory_store import MemoryStore +from exgentic.core.types import ModelSettings + +from task_ordering import get_unified_task_order, group_by_benchmark + + +BENCHMARK_REGISTRY: dict[str, dict[str, Any]] = { + "browsecompplus": { + "bm_kwargs": { + "searcher_type": "faiss", + "include_get_document": True, + "eval_model_id": "openai/gpt-5.4", + }, + "agent_kwargs": {}, + }, + "swebench": { + "bm_kwargs": { + "subset": "princeton-nlp/SWE-bench_Verified", + }, + "agent_kwargs": {}, + }, + "appworld": { + "bm_kwargs": { + "subset": "test_challenge", + }, + "agent_kwargs": { + "enable_tool_shortlisting": True, + "max_selected_tools": 30, + }, + }, + "bfcl": { + "bm_kwargs": { + "subset": "multi_turn_base", + }, + "agent_kwargs": {}, + }, + "tau2": { + "bm_kwargs": { + "subset": "telecom", + "user_simulator_model": "openai/gpt-5.4", + }, + "agent_kwargs": {}, + }, + "hle": { + "bm_kwargs": { + "judge_model": "openai/gpt-5.4", + "runner": "direct", + }, + "agent_kwargs": {}, + }, +} + + +def extract_token_counts(cost_reports: dict) -> tuple[int, int]: + total_in, total_out = 0, 0 + for report in cost_reports.values(): + if isinstance(report, dict): + total_in += report.get("input_tokens", 0) + total_out += report.get("output_tokens", 0) + elif hasattr(report, "input_tokens"): + total_in += report.input_tokens + total_out += report.output_tokens + return total_in, total_out + + +def get_memory_stats(mode: str, bm_slug: str) -> tuple[int, int]: + stores = MemoryStore.list_stores() + if mode == "isolated": + store = stores.get(f"amem_isolated_{bm_slug}") + elif mode == "sequential": + store = stores.get("amem_sequential_global") + elif mode == "interleaved": + store = stores.get("amem_interleaved_global") + else: + return 0, 0 + if store is None: + return 0, 0 + stats = store.get_stats() + return stats["total_memories"], stats["total_evolutions"] + + +def record_online_metrics( + metrics_path: Path, + session_index: int, + bm_slug: str, + task_id: str, + sr: Any, + mode: str, + seed: int, + model: str, + all_scores: list[float], + bm_scores: dict[str, list[float]], +): + score = sr.score if sr.score is not None else (1.0 if sr.success else 0.0) + all_scores.append(score) + bm_scores[bm_slug].append(score) + + input_tokens, output_tokens = extract_token_counts(sr.cost_reports) + memory_count, total_evolutions = get_memory_stats(mode, bm_slug) + + record = { + "session_index": session_index, + "seed": seed, + "mode": mode, + "agent": "a_mem", + "model": model, + "benchmark_slug": bm_slug, + "task_id": task_id, + "score": score, + "cumulative_avg_score": sum(all_scores) / len(all_scores), + "benchmark_cumulative_avg_score": ( + sum(bm_scores[bm_slug]) / len(bm_scores[bm_slug]) + ), + "steps": sr.steps, + "action_count": sr.action_count, + "agent_cost": sr.agent_cost, + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "memory_count": memory_count, + "total_evolutions": total_evolutions, + "execution_time": sr.execution_time, + "status": sr.status.value if hasattr(sr.status, "value") else str(sr.status), + "timestamp": datetime.now().isoformat(), + } + + with open(metrics_path, "a", encoding="utf-8") as f: + f.write(json.dumps(record, ensure_ascii=False) + "\n") + + return record + + + +def run_experiment(args): + benchmarks_to_run = [s.strip() for s in args.benchmarks.split(",")] + configs = {k: BENCHMARK_REGISTRY[k] for k in benchmarks_to_run} + + settings_kwargs = {} + if args.max_tokens is not None: + settings_kwargs["max_tokens"] = args.max_tokens + if args.reasoning_effort is not None: + settings_kwargs["reasoning_effort"] = args.reasoning_effort + model_settings = ModelSettings(**settings_kwargs) + + print(f"\n{'=' * 70}") + print(f" A-Mem Experiment: mode={args.mode} seed={args.seed}") + print(f" model={args.model} memory_model={args.memory_model}") + print(f" num_tasks={args.num_tasks} retrieve_k={args.retrieve_k}") + print(f" model_settings={settings_kwargs or 'default'}") + print(f" benchmarks={benchmarks_to_run}") + print(f" output_dir={args.output_dir}") + print(f"{'=' * 70}\n") + + task_order = get_unified_task_order(configs, args.num_tasks, args.seed, args.mode) + print(f"Total tasks: {len(task_order)}") + + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + metrics_path = output_dir / "online_metrics.jsonl" + + exp_config = { + "mode": args.mode, + "seed": args.seed, + "agent": "a_mem", + "model": args.model, + "memory_model": args.memory_model, + "retrieve_k": args.retrieve_k, + "evo_threshold": args.evo_threshold, + "embedding_model": args.embedding_model, + "num_tasks": args.num_tasks, + "benchmarks": benchmarks_to_run, + "task_order": [(s, t) for s, t in task_order], + } + with open(output_dir / "experiment_config.json", "w") as f: + json.dump(exp_config, f, indent=2) + + + MemoryStore.reset_all() + if args.mode == "interleaved": + _ckpt_ids = ["amem_interleaved_global"] + elif args.mode == "sequential": + _ckpt_ids = ["amem_sequential_global"] + else: + _ckpt_ids = [f"amem_isolated_{b}" for b in benchmarks_to_run] + + _restored = False + _restored_session_count = 0 + for sid in _ckpt_ids: + ckpt_path = output_dir / f"memory_{sid}.json" + if ckpt_path.exists(): + store = MemoryStore.get_or_create( + shuffle_mode=args.mode, + benchmark_id=sid.replace("amem_isolated_", "") if args.mode == "isolated" else None, + embedding_model=args.embedding_model, + evo_threshold=args.evo_threshold, + ) + store.load_checkpoint(str(ckpt_path)) + _restored_session_count = max(_restored_session_count, store.session_count) + _restored = True + if _restored: + print(f" Restored memory from checkpoint (session_count={_restored_session_count})") + + all_scores: list[float] = [] + bm_scores: defaultdict[str, list[float]] = defaultdict(list) + session_index = 0 + + if metrics_path.exists(): + kept_lines: list[str] = [] + with open(metrics_path, "r") as f: + for line in f: + if _restored and session_index >= _restored_session_count: + break + rec = json.loads(line) + all_scores.append(rec["score"]) + bm_scores[rec["benchmark_slug"]].append(rec["score"]) + kept_lines.append(line) + session_index += 1 + with open(metrics_path, "w") as f: + f.writelines(kept_lines) + if session_index > 0: + print(f" Restored {session_index} metrics records (cum_avg={sum(all_scores)/len(all_scores):.3f})") + + if args.mode in ("isolated", "sequential"): + _completed_benchmarks: set[str] = set() + if _restored and session_index > 0: + _bm_counts: dict[str, int] = defaultdict(int) + with open(metrics_path, "r") as f: + for line in f: + rec = json.loads(line) + _bm_counts[rec["benchmark_slug"]] += 1 + for bm_slug, task_ids in group_by_benchmark(task_order): + if _bm_counts.get(bm_slug, 0) >= len(task_ids): + _completed_benchmarks.add(bm_slug) + if _completed_benchmarks: + print(f" Skipping completed benchmarks: {sorted(_completed_benchmarks)}") + + for bm_slug, task_ids in group_by_benchmark(task_order): + if bm_slug in _completed_benchmarks: + continue + + print(f"\n{'=' * 60}") + print(f" {args.mode.upper()} — {bm_slug} ({len(task_ids)} tasks)") + print(f"{'=' * 60}\n") + + bm_kwargs = configs[bm_slug]["bm_kwargs"] + agent_kwargs = configs[bm_slug].get("agent_kwargs", {}) + + benchmark = load_benchmark(bm_slug)(**bm_kwargs) + agent = load_agent("a_mem")( + model=args.model, + memory_model=args.memory_model, + shuffle_mode=args.mode, + benchmark_id=bm_slug, + retrieve_k=args.retrieve_k, + evo_threshold=args.evo_threshold, + embedding_model=args.embedding_model, + runner="direct", + model_settings=model_settings, + **agent_kwargs, + ) + + results = evaluate( + benchmark=benchmark, + agent=agent, + task_ids=task_ids, + max_workers=1, + output_dir=str(output_dir), + ) + + print(f" {bm_slug} score={results.benchmark_score}") + + stores = MemoryStore.list_stores() + store_key = (f"amem_isolated_{bm_slug}" if args.mode == "isolated" + else "amem_sequential_global") + store = stores.get(store_key) + if store: + stats = store.get_stats() + print(f" memory: sessions={store.session_count}, " + f"memories={stats['total_memories']}, " + f"evolutions={stats['total_evolutions']}") + store.save_checkpoint(str(output_dir / f"memory_{store_key}.json")) + + for i, sr in enumerate(results.session_results): + tid = task_ids[i] if i < len(task_ids) else sr.task_id or "?" + rec = record_online_metrics( + metrics_path, session_index, bm_slug, tid, sr, + args.mode, args.seed, args.model, + all_scores, bm_scores, + ) + print(f" [{session_index}] {bm_slug}::{tid} " + f"score={rec['score']:.2f} cum={rec['cumulative_avg_score']:.3f} " + f"steps={rec['steps']}") + session_index += 1 + + elif args.mode == "interleaved": + for i, (bm_slug, task_id) in enumerate(task_order): + if _restored and i < _restored_session_count: + print(f" Skipping Interleaved [{i+1}/{len(task_order)}] {bm_slug}::{task_id} (cached)") + continue + + print(f"\n--- Interleaved [{i+1}/{len(task_order)}] {bm_slug}::{task_id} ---") + + bm_kwargs = configs[bm_slug]["bm_kwargs"] + agent_kwargs = configs[bm_slug].get("agent_kwargs", {}) + + benchmark = load_benchmark(bm_slug)(**bm_kwargs) + agent = load_agent("a_mem")( + model=args.model, + memory_model=args.memory_model, + shuffle_mode="interleaved", + benchmark_id=bm_slug, + retrieve_k=args.retrieve_k, + evo_threshold=args.evo_threshold, + embedding_model=args.embedding_model, + runner="direct", + model_settings=model_settings, + **agent_kwargs, + ) + + results = evaluate( + benchmark=benchmark, + agent=agent, + task_ids=[task_id], + max_workers=1, + output_dir=str(output_dir), + ) + + sr = results.session_results[0] + rec = record_online_metrics( + metrics_path, session_index, bm_slug, task_id, sr, + "interleaved", args.seed, args.model, + all_scores, bm_scores, + ) + print(f" score={rec['score']:.2f} cum={rec['cumulative_avg_score']:.3f} " + f"steps={rec['steps']}") + + stores = MemoryStore.list_stores() + store = stores.get("amem_interleaved_global") + if store: + stats = store.get_stats() + print(f" memory: sessions={store.session_count}, " + f"memories={stats['total_memories']}, " + f"evolutions={stats['total_evolutions']}") + store.save_checkpoint(str(output_dir / "memory_amem_interleaved_global.json")) + session_index += 1 + + print(f"\n{'=' * 70}") + print(f" Final Summary") + print(f"{'=' * 70}") + if all_scores: + print(f" Overall avg score: {sum(all_scores)/len(all_scores):.3f}") + for bm, scores in sorted(bm_scores.items()): + print(f" {bm:20s}: avg={sum(scores)/len(scores):.3f} n={len(scores)}") + + stores = MemoryStore.list_stores() + for store_id, store in stores.items(): + stats = store.get_stats() + print(f" memory[{store_id}]: sessions={store.session_count}, " + f"memories={stats['total_memories']}, " + f"evolutions={stats['total_evolutions']}, " + f"avg_links={stats['avg_links']:.2f}") + + print(f"\n Metrics: {metrics_path}") + print(f" Output: {output_dir}") + + +def main(): + parser = argparse.ArgumentParser(description="Run A-Mem test-time learning experiment") + parser.add_argument("--mode", required=True, choices=["isolated", "sequential", "interleaved"]) + parser.add_argument("--seed", type=int, required=True) + parser.add_argument("--num-tasks", type=int, default=50, help="Tasks per benchmark") + parser.add_argument("--model", default="openai/gpt-5.4") + parser.add_argument("--memory-model", default=None, + help="Model for memory evolution (defaults to --model)") + parser.add_argument("--benchmarks", default="browsecompplus,swebench,bfcl,tau2", + help="Comma-separated benchmark slugs") + parser.add_argument("--output-dir", required=True) + parser.add_argument("--max-tokens", type=int, default=None, help="Max output tokens") + parser.add_argument("--reasoning-effort", default=None, help="Reasoning effort (low/medium/high)") + # A-Mem specific hyperparameters + parser.add_argument("--retrieve-k", type=int, default=10, + help="Number of memories to retrieve per query") + parser.add_argument("--evo-threshold", type=int, default=100, + help="Consolidate embeddings every N evolutions") + parser.add_argument("--embedding-model", default="all-MiniLM-L6-v2", + help="SentenceTransformer model for memory retrieval") + args = parser.parse_args() + + if args.memory_model is None: + args.memory_model = args.model + + run_experiment(args) + + +if __name__ == "__main__": + main() diff --git a/labs/AgentStream/exgentic/scripts/a_mem/run_experiment.sh b/labs/AgentStream/exgentic/scripts/a_mem/run_experiment.sh new file mode 100644 index 00000000..e9036dd0 --- /dev/null +++ b/labs/AgentStream/exgentic/scripts/a_mem/run_experiment.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +set -e + +export OPENAI_API_BASE="" +export OPENAI_API_KEY="" + +cd "$(dirname "$0")" + +# ============================================================ +# Configuration +# ============================================================ +SEED=44 +NUM_TASKS=50 +MODEL="openai/gpt-5.4" +MEMORY_MODEL="openai/gpt-5.4" +MAX_TOKENS="default" +REASONING_EFFORT="default" +MODE="sequential" # isolated | sequential | interleaved + +# A-Mem specific hyperparameters +RETRIEVE_K=10 +EVO_THRESHOLD=100 +EMBEDDING_MODEL="all-MiniLM-L6-v2" + +OUTPUT_BASE="./outputs" +MODEL_SHORT=$(echo $MODEL | sed 's|openai/||; s|azure/||; s|/|_|g') +RUN_TAG="amem_${MODE}_s${SEED}_${MODEL_SHORT}_${MAX_TOKENS}_${REASONING_EFFORT}" + + +SETTINGS_ARGS="" +[ "$MAX_TOKENS" != "default" ] && SETTINGS_ARGS="$SETTINGS_ARGS --max-tokens $MAX_TOKENS" +[ "$REASONING_EFFORT" != "default" ] && SETTINGS_ARGS="$SETTINGS_ARGS --reasoning-effort $REASONING_EFFORT" +echo "Mode: ${MODE}" +echo "ModelSettings args: ${SETTINGS_ARGS:-default (no overrides)}" +echo "A-Mem: retrieve_k=${RETRIEVE_K} evo_threshold=${EVO_THRESHOLD} embedding=${EMBEDDING_MODEL}" + +mkdir -p "$OUTPUT_BASE" + +ALL_BENCHMARKS="hle,bfcl,browsecompplus,appworld,swebench,tau2" +if [ "$MODE" = "isolated" ]; then + for BENCH in swebench tau2 browsecompplus appworld hle bfcl; do + echo "=== Running isolated ${BENCH} ===" + uv run python run_experiment.py \ + --mode isolated --seed $SEED --num-tasks $NUM_TASKS \ + --model $MODEL --memory-model $MEMORY_MODEL \ + --retrieve-k $RETRIEVE_K --evo-threshold $EVO_THRESHOLD \ + --embedding-model $EMBEDDING_MODEL \ + $SETTINGS_ARGS \ + --benchmarks $BENCH \ + --output-dir ${OUTPUT_BASE}/${RUN_TAG}_${BENCH} \ + 2>&1 | tee ${OUTPUT_BASE}/${RUN_TAG}_${BENCH}.log + done +else + echo "=== Running ${MODE} (all benchmarks) ===" + uv run python run_experiment.py \ + --mode $MODE --seed $SEED --num-tasks $NUM_TASKS \ + --model $MODEL --memory-model $MEMORY_MODEL \ + --retrieve-k $RETRIEVE_K --evo-threshold $EVO_THRESHOLD \ + --embedding-model $EMBEDDING_MODEL \ + $SETTINGS_ARGS \ + --benchmarks $ALL_BENCHMARKS \ + --output-dir ${OUTPUT_BASE}/${RUN_TAG}_all \ + 2>&1 | tee ${OUTPUT_BASE}/${RUN_TAG}_all.log +fi + +echo "" +echo "=== All benchmarks complete ===" +echo "Run tag: ${RUN_TAG}" +echo "Outputs in: ${OUTPUT_BASE}/${RUN_TAG}_*" diff --git a/labs/AgentStream/exgentic/scripts/ace/run_experiment.py b/labs/AgentStream/exgentic/scripts/ace/run_experiment.py new file mode 100644 index 00000000..b1ab9dba --- /dev/null +++ b/labs/AgentStream/exgentic/scripts/ace/run_experiment.py @@ -0,0 +1,386 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +import argparse +import json +import os +import sys +from collections import defaultdict +from datetime import datetime +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src")) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "utils")) + +os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + +# --- ExGentic imports --- +from exgentic.interfaces.lib.api import evaluate +from exgentic.interfaces.registry import load_agent, load_benchmark +from exgentic.agents.ace.playbook_store import PlaybookStore +from exgentic.agents.ace.playbook_utils import get_playbook_stats +from exgentic.core.types import ModelSettings + +from task_ordering import get_unified_task_order, group_by_benchmark + + +BENCHMARK_REGISTRY: dict[str, dict[str, Any]] = { + "browsecompplus": { + "bm_kwargs": { + "searcher_type": "faiss", + "include_get_document": True, + "eval_model_id": "openai/gpt-5.4", + }, + "agent_kwargs": {}, + }, + "swebench": { + "bm_kwargs": { + "subset": "princeton-nlp/SWE-bench_Verified", + }, + "agent_kwargs": {}, + }, + "appworld": { + "bm_kwargs": { + "subset": "test_challenge", + }, + "agent_kwargs": { + "enable_tool_shortlisting": True, + "max_selected_tools": 30, + }, + }, + "bfcl": { + "bm_kwargs": { + "subset": "multi_turn_base", + }, + "agent_kwargs": {}, + }, + "tau2": { + "bm_kwargs": { + "subset": "telecom", + "user_simulator_model": "openai/gpt-5.4", + }, + "agent_kwargs": {}, + }, + "hle": { + "bm_kwargs": { + "judge_model": "openai/gpt-5.4", + "runner": "direct", + }, + "agent_kwargs": {}, + }, +} + +def extract_token_counts(cost_reports: dict) -> tuple[int, int]: + """Extract total input/output tokens from cost_reports dict.""" + total_in, total_out = 0, 0 + for report in cost_reports.values(): + if isinstance(report, dict): + total_in += report.get("input_tokens", 0) + total_out += report.get("output_tokens", 0) + elif hasattr(report, "input_tokens"): + total_in += report.input_tokens + total_out += report.output_tokens + return total_in, total_out + + +def get_memory_tokens(mode: str, bm_slug: str) -> tuple[int, int]: + stores = PlaybookStore.list_stores() + if mode == "isolated": + store = stores.get(f"ace_isolated_{bm_slug}") + elif mode == "sequential": + store = stores.get("ace_sequential_global") + elif mode == "interleaved": + store = stores.get("ace_interleaved_global") + else: + return 0, 0 + if store is None: + return 0, 0 + stats = get_playbook_stats(store.playbook) + mem_tokens = len(store.playbook) // 4 + return mem_tokens, stats["total_bullets"] + + + +def record_online_metrics( + metrics_path: Path, + session_index: int, + bm_slug: str, + task_id: str, + sr: Any, + mode: str, + seed: int, + model: str, + all_scores: list[float], + bm_scores: dict[str, list[float]], +): + score = sr.score if sr.score is not None else (1.0 if sr.success else 0.0) + all_scores.append(score) + bm_scores[bm_slug].append(score) + + input_tokens, output_tokens = extract_token_counts(sr.cost_reports) + memory_tokens, playbook_bullets = get_memory_tokens(mode, bm_slug) + + record = { + "session_index": session_index, + "seed": seed, + "mode": mode, + "agent": "ace", + "model": model, + "benchmark_slug": bm_slug, + "task_id": task_id, + "score": score, + "cumulative_avg_score": sum(all_scores) / len(all_scores), + "benchmark_cumulative_avg_score": ( + sum(bm_scores[bm_slug]) / len(bm_scores[bm_slug]) + ), + "steps": sr.steps, + "action_count": sr.action_count, + "agent_cost": sr.agent_cost, + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "memory_tokens": memory_tokens, + "playbook_bullets": playbook_bullets, + "execution_time": sr.execution_time, + "status": sr.status.value if hasattr(sr.status, "value") else str(sr.status), + "timestamp": datetime.now().isoformat(), + } + + with open(metrics_path, "a", encoding="utf-8") as f: + f.write(json.dumps(record, ensure_ascii=False) + "\n") + + return record + +def run_experiment(args): + benchmarks_to_run = [s.strip() for s in args.benchmarks.split(",")] + configs = {k: BENCHMARK_REGISTRY[k] for k in benchmarks_to_run} + + settings_kwargs = {} + if args.max_tokens is not None: + settings_kwargs["max_tokens"] = args.max_tokens + if args.reasoning_effort is not None: + settings_kwargs["reasoning_effort"] = args.reasoning_effort + model_settings = ModelSettings(**settings_kwargs) + + print(f"\n{'=' * 70}") + print(f" ACE Experiment: mode={args.mode} seed={args.seed}") + print(f" model={args.model} num_tasks={args.num_tasks}") + print(f" model_settings={settings_kwargs or 'default'}") + print(f" benchmarks={benchmarks_to_run}") + print(f" output_dir={args.output_dir}") + print(f"{'=' * 70}\n") + + task_order = get_unified_task_order(configs, args.num_tasks, args.seed, args.mode) + print(f"Total tasks: {len(task_order)}") + + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + metrics_path = output_dir / "online_metrics.jsonl" + + exp_config = { + "mode": args.mode, + "seed": args.seed, + "agent": "ace", + "model": args.model, + "num_tasks": args.num_tasks, + "benchmarks": benchmarks_to_run, + "task_order": [(s, t) for s, t in task_order], + } + with open(output_dir / "experiment_config.json", "w") as f: + json.dump(exp_config, f, indent=2) + + PlaybookStore.reset_all() + if args.mode == "interleaved": + _ckpt_ids = ["ace_interleaved_global"] + elif args.mode == "sequential": + _ckpt_ids = ["ace_sequential_global"] + else: + _ckpt_ids = [f"ace_isolated_{b}" for b in benchmarks_to_run] + + _restored = False + _restored_session_count = 0 + for sid in _ckpt_ids: + ckpt_path = output_dir / f"playbook_{sid}.json" + if ckpt_path.exists(): + store = PlaybookStore.get_or_create( + shuffle_mode=args.mode, + benchmark_id=sid.replace("ace_isolated_", "") if args.mode == "isolated" else None, + ) + store.load_checkpoint(str(ckpt_path)) + _restored_session_count = max(_restored_session_count, store.session_count) + _restored = True + if _restored: + print(f" ♻️ Restored playbook from checkpoint (session_count={_restored_session_count})") + + all_scores: list[float] = [] + bm_scores: defaultdict[str, list[float]] = defaultdict(list) + session_index = 0 + + if metrics_path.exists(): + kept_lines: list[str] = [] + with open(metrics_path, "r") as f: + for line in f: + if _restored and session_index >= _restored_session_count: + break + rec = json.loads(line) + all_scores.append(rec["score"]) + bm_scores[rec["benchmark_slug"]].append(rec["score"]) + kept_lines.append(line) + session_index += 1 + with open(metrics_path, "w") as f: + f.writelines(kept_lines) + if session_index > 0: + print(f" ♻️ Restored {session_index} metrics records (cum_avg={sum(all_scores)/len(all_scores):.3f})") + + if args.mode in ("isolated", "sequential"): + _completed_benchmarks: set[str] = set() + if _restored and session_index > 0: + _bm_counts: dict[str, int] = defaultdict(int) + with open(metrics_path, "r") as f: + for line in f: + rec = json.loads(line) + _bm_counts[rec["benchmark_slug"]] += 1 + for bm_slug, task_ids in group_by_benchmark(task_order): + if _bm_counts.get(bm_slug, 0) >= len(task_ids): + _completed_benchmarks.add(bm_slug) + if _completed_benchmarks: + print(f" ⏭️ Skipping completed benchmarks: {sorted(_completed_benchmarks)}") + + for bm_slug, task_ids in group_by_benchmark(task_order): + if bm_slug in _completed_benchmarks: + continue + + print(f"\n{'=' * 60}") + print(f" {args.mode.upper()} — {bm_slug} ({len(task_ids)} tasks)") + print(f"{'=' * 60}\n") + + bm_kwargs = configs[bm_slug]["bm_kwargs"] + agent_kwargs = configs[bm_slug].get("agent_kwargs", {}) + + benchmark = load_benchmark(bm_slug)(**bm_kwargs) + agent = load_agent("ace")( + model=args.model, + curator_model=args.model, + shuffle_mode=args.mode, + benchmark_id=bm_slug, + runner="direct", + model_settings=model_settings, + **agent_kwargs, + ) + + results = evaluate( + benchmark=benchmark, + agent=agent, + task_ids=task_ids, + max_workers=1, + output_dir=str(output_dir), + ) + + print(f" {bm_slug} score={results.benchmark_score}") + + stores = PlaybookStore.list_stores() + store_key = (f"ace_isolated_{bm_slug}" if args.mode == "isolated" + else "ace_sequential_global") + store = stores.get(store_key) + if store: + stats = get_playbook_stats(store.playbook) + print(f" playbook: sessions={store.session_count}, " + f"bullets={stats['total_bullets']}") + store.save_checkpoint(str(output_dir / f"playbook_{store_key}.json")) + + for i, sr in enumerate(results.session_results): + tid = task_ids[i] if i < len(task_ids) else sr.task_id or "?" + rec = record_online_metrics( + metrics_path, session_index, bm_slug, tid, sr, + args.mode, args.seed, args.model, + all_scores, bm_scores, + ) + print(f" [{session_index}] {bm_slug}::{tid} " + f"score={rec['score']:.2f} cum={rec['cumulative_avg_score']:.3f} " + f"steps={rec['steps']}") + session_index += 1 + + elif args.mode == "interleaved": + for i, (bm_slug, task_id) in enumerate(task_order): + if _restored and i < _restored_session_count: + print(f" ⏭️ Skipping Interleaved [{i+1}/{len(task_order)}] {bm_slug}::{task_id} (cached)") + continue + + print(f"\n--- Interleaved [{i+1}/{len(task_order)}] {bm_slug}::{task_id} ---") + + bm_kwargs = configs[bm_slug]["bm_kwargs"] + agent_kwargs = configs[bm_slug].get("agent_kwargs", {}) + + benchmark = load_benchmark(bm_slug)(**bm_kwargs) + agent = load_agent("ace")( + model=args.model, + curator_model=args.model, + shuffle_mode="interleaved", + benchmark_id=bm_slug, + runner="direct", + model_settings=model_settings, + **agent_kwargs, + ) + + results = evaluate( + benchmark=benchmark, + agent=agent, + task_ids=[task_id], + max_workers=1, + output_dir=str(output_dir), + ) + + sr = results.session_results[0] + + rec = record_online_metrics( + metrics_path, session_index, bm_slug, task_id, sr, + "interleaved", args.seed, args.model, + all_scores, bm_scores, + ) + print(f" score={rec['score']:.2f} cum={rec['cumulative_avg_score']:.3f} " + f"steps={rec['steps']}") + stores = PlaybookStore.list_stores() + store = stores.get("ace_interleaved_global") + if store: + stats = get_playbook_stats(store.playbook) + print(f" playbook: sessions={store.session_count}, " + f"bullets={stats['total_bullets']}") + store.save_checkpoint(str(output_dir / "playbook_ace_interleaved_global.json")) + session_index += 1 + + print(f"\n{'=' * 70}") + print(f" Final Summary") + print(f"{'=' * 70}") + print(f" Overall avg score: {sum(all_scores)/len(all_scores):.3f}") + for bm, scores in sorted(bm_scores.items()): + print(f" {bm:20s}: avg={sum(scores)/len(scores):.3f} n={len(scores)}") + + stores = PlaybookStore.list_stores() + for store_id, store in stores.items(): + stats = get_playbook_stats(store.playbook) + print(f" playbook[{store_id}]: sessions={store.session_count}, " + f"bullets={stats['total_bullets']}") + + print(f"\n Metrics: {metrics_path}") + print(f" Output: {output_dir}") + + +def main(): + parser = argparse.ArgumentParser(description="Run ACE test-time learning experiment") + parser.add_argument("--mode", required=True, choices=["isolated", "sequential", "interleaved"]) + parser.add_argument("--seed", type=int, required=True) + parser.add_argument("--num-tasks", type=int, default=50, help="Tasks per benchmark") + parser.add_argument("--model", default="openai/gpt-5.4") + parser.add_argument("--benchmarks", default="browsecompplus,swebench,bfcl,tau2", + help="Comma-separated benchmark slugs") + parser.add_argument("--output-dir", required=True) + parser.add_argument("--max-tokens", type=int, default=None, help="Max output tokens") + parser.add_argument("--reasoning-effort", default=None, help="Reasoning effort (low/medium/high)") + args = parser.parse_args() + run_experiment(args) + + +if __name__ == "__main__": + main() diff --git a/labs/AgentStream/exgentic/scripts/ace/run_experiment.sh b/labs/AgentStream/exgentic/scripts/ace/run_experiment.sh new file mode 100644 index 00000000..c1ad04cd --- /dev/null +++ b/labs/AgentStream/exgentic/scripts/ace/run_experiment.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +set -e + +export OPENAI_API_BASE="" +export OPENAI_API_KEY="" + +cd "$(dirname "$0")" + +# ============================================================ +# Configuration +# ============================================================ +SEED=44 +NUM_TASKS=50 +MODEL="openai/gpt-5.4" +MAX_TOKENS="default" +REASONING_EFFORT="default" +MODE="sequential" # isolated | sequential | interleaved + + +OUTPUT_BASE="./outputs" +MODEL_SHORT=$(echo $MODEL | sed 's|openai/||; s|azure/||; s|/|_|g') +RUN_TAG="ace_${MODE}_s${SEED}_${MODEL_SHORT}_${MAX_TOKENS}_${REASONING_EFFORT}" + +SETTINGS_ARGS="" +[ "$MAX_TOKENS" != "default" ] && SETTINGS_ARGS="$SETTINGS_ARGS --max-tokens $MAX_TOKENS" +[ "$REASONING_EFFORT" != "default" ] && SETTINGS_ARGS="$SETTINGS_ARGS --reasoning-effort $REASONING_EFFORT" +echo "Mode: ${MODE}" +echo "ModelSettings args: ${SETTINGS_ARGS:-default (no overrides)}" + +mkdir -p "$OUTPUT_BASE" + +ALL_BENCHMARKS="hle,bfcl,browsecompplus,appworld,swebench,tau2" + +if [ "$MODE" = "isolated" ]; then + for BENCH in swebench tau2 browsecompplus appworld hle bfcl; do + echo "=== Running isolated ${BENCH} ===" + uv run python run_experiment.py \ + --mode isolated --seed $SEED --num-tasks $NUM_TASKS \ + --model $MODEL $SETTINGS_ARGS \ + --benchmarks $BENCH \ + --output-dir ${OUTPUT_BASE}/${RUN_TAG}_${BENCH} \ + 2>&1 | tee ${OUTPUT_BASE}/${RUN_TAG}_${BENCH}.log + done +else + echo "=== Running ${MODE} (all benchmarks) ===" + uv run python run_experiment.py \ + --mode $MODE --seed $SEED --num-tasks $NUM_TASKS \ + --model $MODEL $SETTINGS_ARGS \ + --benchmarks $ALL_BENCHMARKS \ + --output-dir ${OUTPUT_BASE}/${RUN_TAG}_all \ + 2>&1 | tee ${OUTPUT_BASE}/${RUN_TAG}_all.log +fi + +echo "" +echo "=== All benchmarks complete ===" +echo "Run tag: ${RUN_TAG}" +echo "Outputs in: ${OUTPUT_BASE}/${RUN_TAG}_*" diff --git a/labs/AgentStream/exgentic/scripts/autoskill/run_experiment.py b/labs/AgentStream/exgentic/scripts/autoskill/run_experiment.py new file mode 100644 index 00000000..0c4f756b --- /dev/null +++ b/labs/AgentStream/exgentic/scripts/autoskill/run_experiment.py @@ -0,0 +1,382 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +import argparse +import json +import os +import sys +from collections import defaultdict +from datetime import datetime +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src")) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "utils")) + +os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + +from exgentic.interfaces.lib.api import evaluate +from exgentic.interfaces.registry import load_agent, load_benchmark +from exgentic.agents.autoskill.skill_store import SkillStore +from exgentic.core.types import ModelSettings + +from task_ordering import get_unified_task_order, group_by_benchmark + +BENCHMARK_REGISTRY: dict[str, dict[str, Any]] = { + "browsecompplus": { + "bm_kwargs": { + "searcher_type": "faiss", + "include_get_document": True, + "eval_model_id": "openai/gpt-5.4", + }, + "agent_kwargs": {}, + }, + "swebench": { + "bm_kwargs": { + "subset": "princeton-nlp/SWE-bench_Verified", + }, + "agent_kwargs": {}, + }, + "appworld": { + "bm_kwargs": { + "subset": "test_challenge", + }, + "agent_kwargs": { + "enable_tool_shortlisting": True, + "max_selected_tools": 30, + }, + }, + "bfcl": { + "bm_kwargs": { + "subset": "multi_turn_base", + }, + "agent_kwargs": {}, + }, + "tau2": { + "bm_kwargs": { + "subset": "telecom", + "user_simulator_model": "openai/gpt-5.4", + }, + "agent_kwargs": {}, + }, + "hle": { + "bm_kwargs": { + "judge_model": "openai/gpt-5.4", + "runner": "direct", + }, + "agent_kwargs": {}, + }, +} + +def extract_token_counts(cost_reports: dict) -> tuple[int, int]: + """Extract total input/output tokens from cost_reports dict.""" + total_in, total_out = 0, 0 + for report in cost_reports.values(): + if isinstance(report, dict): + total_in += report.get("input_tokens", 0) + total_out += report.get("output_tokens", 0) + elif hasattr(report, "input_tokens"): + total_in += report.input_tokens + total_out += report.output_tokens + return total_in, total_out + + +def get_memory_tokens(mode: str, bm_slug: str) -> tuple[int, int]: + """Return (memory_tokens, skill_count) for current skill store state.""" + stores = SkillStore.list_stores() + if mode == "isolated": + store = stores.get(f"autoskill_isolated_{bm_slug}") + elif mode == "sequential": + store = stores.get("autoskill_sequential_global") + elif mode == "interleaved": + store = stores.get("autoskill_interleaved_global") + else: + return 0, 0 + if store is None: + return 0, 0 + skills = store.list_skills() + total_chars = sum(len(s.to_search_text()) for s in skills) + mem_tokens = total_chars // 4 + return mem_tokens, len(skills) + +def record_online_metrics( + metrics_path: Path, + session_index: int, + bm_slug: str, + task_id: str, + sr: Any, + mode: str, + seed: int, + model: str, + all_scores: list[float], + bm_scores: dict[str, list[float]], +): + score = sr.score if sr.score is not None else (1.0 if sr.success else 0.0) + all_scores.append(score) + bm_scores[bm_slug].append(score) + + input_tokens, output_tokens = extract_token_counts(sr.cost_reports) + memory_tokens, skill_count = get_memory_tokens(mode, bm_slug) + + record = { + "session_index": session_index, + "seed": seed, + "mode": mode, + "agent": "autoskill", + "model": model, + "benchmark_slug": bm_slug, + "task_id": task_id, + "score": score, + "cumulative_avg_score": sum(all_scores) / len(all_scores), + "benchmark_cumulative_avg_score": ( + sum(bm_scores[bm_slug]) / len(bm_scores[bm_slug]) + ), + "steps": sr.steps, + "action_count": sr.action_count, + "agent_cost": sr.agent_cost, + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "memory_tokens": memory_tokens, + "skill_count": skill_count, + "execution_time": sr.execution_time, + "status": sr.status.value if hasattr(sr.status, "value") else str(sr.status), + "timestamp": datetime.now().isoformat(), + } + + with open(metrics_path, "a", encoding="utf-8") as f: + f.write(json.dumps(record, ensure_ascii=False) + "\n") + + return record + +def run_experiment(args): + benchmarks_to_run = [s.strip() for s in args.benchmarks.split(",")] + configs = {k: BENCHMARK_REGISTRY[k] for k in benchmarks_to_run} + + settings_kwargs = {} + if args.max_tokens is not None: + settings_kwargs["max_tokens"] = args.max_tokens + if args.reasoning_effort is not None: + settings_kwargs["reasoning_effort"] = args.reasoning_effort + model_settings = ModelSettings(**settings_kwargs) + + print(f"\n{'=' * 70}") + print(f" AutoSkill Experiment: mode={args.mode} seed={args.seed}") + print(f" model={args.model} num_tasks={args.num_tasks}") + print(f" model_settings={settings_kwargs or 'default'}") + print(f" benchmarks={benchmarks_to_run}") + print(f" output_dir={args.output_dir}") + print(f"{'=' * 70}\n") + + task_order = get_unified_task_order(configs, args.num_tasks, args.seed, args.mode) + print(f"Total tasks: {len(task_order)}") + + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + metrics_path = output_dir / "online_metrics.jsonl" + + exp_config = { + "mode": args.mode, + "seed": args.seed, + "agent": "autoskill", + "model": args.model, + "num_tasks": args.num_tasks, + "benchmarks": benchmarks_to_run, + "task_order": [(s, t) for s, t in task_order], + } + with open(output_dir / "experiment_config.json", "w") as f: + json.dump(exp_config, f, indent=2) + + SkillStore.reset_all() + if args.mode == "interleaved": + _ckpt_ids = ["autoskill_interleaved_global"] + elif args.mode == "sequential": + _ckpt_ids = ["autoskill_sequential_global"] + else: + _ckpt_ids = [f"autoskill_isolated_{b}" for b in benchmarks_to_run] + + _restored = False + _restored_session_count = 0 + for sid in _ckpt_ids: + ckpt_path = output_dir / f"skillstore_{sid}.json" + if ckpt_path.exists(): + store = SkillStore.get_or_create( + shuffle_mode=args.mode, + benchmark_id=sid.replace("autoskill_isolated_", "") if args.mode == "isolated" else None, + ) + store.load_checkpoint(str(ckpt_path)) + _restored_session_count = max(_restored_session_count, store.session_count) + _restored = True + if _restored: + print(f" ♻️ Restored skill store from checkpoint (session_count={_restored_session_count})") + + all_scores: list[float] = [] + bm_scores: defaultdict[str, list[float]] = defaultdict(list) + session_index = 0 + + if metrics_path.exists(): + kept_lines: list[str] = [] + with open(metrics_path, "r") as f: + for line in f: + if _restored and session_index >= _restored_session_count: + break + rec = json.loads(line) + all_scores.append(rec["score"]) + bm_scores[rec["benchmark_slug"]].append(rec["score"]) + kept_lines.append(line) + session_index += 1 + with open(metrics_path, "w") as f: + f.writelines(kept_lines) + if session_index > 0: + print(f" ♻️ Restored {session_index} metrics records (cum_avg={sum(all_scores)/len(all_scores):.3f})") + + if args.mode in ("isolated", "sequential"): + _completed_benchmarks: set[str] = set() + if _restored and session_index > 0: + _bm_counts: dict[str, int] = defaultdict(int) + with open(metrics_path, "r") as f: + for line in f: + rec = json.loads(line) + _bm_counts[rec["benchmark_slug"]] += 1 + for bm_slug, task_ids in group_by_benchmark(task_order): + if _bm_counts.get(bm_slug, 0) >= len(task_ids): + _completed_benchmarks.add(bm_slug) + if _completed_benchmarks: + print(f" ⏭️ Skipping completed benchmarks: {sorted(_completed_benchmarks)}") + + for bm_slug, task_ids in group_by_benchmark(task_order): + if bm_slug in _completed_benchmarks: + continue + + print(f"\n{'=' * 60}") + print(f" {args.mode.upper()} — {bm_slug} ({len(task_ids)} tasks)") + print(f"{'=' * 60}\n") + + bm_kwargs = configs[bm_slug]["bm_kwargs"] + agent_kwargs = configs[bm_slug].get("agent_kwargs", {}) + + benchmark = load_benchmark(bm_slug)(**bm_kwargs) + agent = load_agent("autoskill")( + model=args.model, + skill_model=args.model, + shuffle_mode=args.mode, + benchmark_id=bm_slug, + embedding_model="all-MiniLM-L6-v2", + runner="direct", + model_settings=model_settings, + **agent_kwargs, + ) + + results = evaluate( + benchmark=benchmark, + agent=agent, + task_ids=task_ids, + max_workers=1, + output_dir=str(output_dir), + ) + + print(f" {bm_slug} score={results.benchmark_score}") + stores = SkillStore.list_stores() + store_key = (f"autoskill_isolated_{bm_slug}" if args.mode == "isolated" + else "autoskill_sequential_global") + store = stores.get(store_key) + if store: + print(f" skillbank: sessions={store.session_count}, " + f"skills={store.skill_count}") + store.save_checkpoint(str(output_dir / f"skillstore_{store_key}.json")) + + for i, sr in enumerate(results.session_results): + tid = task_ids[i] if i < len(task_ids) else sr.task_id or "?" + rec = record_online_metrics( + metrics_path, session_index, bm_slug, tid, sr, + args.mode, args.seed, args.model, + all_scores, bm_scores, + ) + print(f" [{session_index}] {bm_slug}::{tid} " + f"score={rec['score']:.2f} cum={rec['cumulative_avg_score']:.3f} " + f"steps={rec['steps']}") + session_index += 1 + + elif args.mode == "interleaved": + for i, (bm_slug, task_id) in enumerate(task_order): + if _restored and i < _restored_session_count: + print(f" ⏭️ Skipping Interleaved [{i+1}/{len(task_order)}] {bm_slug}::{task_id} (cached)") + continue + + print(f"\n--- Interleaved [{i+1}/{len(task_order)}] {bm_slug}::{task_id} ---") + + bm_kwargs = configs[bm_slug]["bm_kwargs"] + agent_kwargs = configs[bm_slug].get("agent_kwargs", {}) + + benchmark = load_benchmark(bm_slug)(**bm_kwargs) + agent = load_agent("autoskill")( + model=args.model, + skill_model=args.model, + shuffle_mode="interleaved", + benchmark_id=bm_slug, + embedding_model="all-MiniLM-L6-v2", + runner="direct", + model_settings=model_settings, + **agent_kwargs, + ) + + results = evaluate( + benchmark=benchmark, + agent=agent, + task_ids=[task_id], + max_workers=1, + output_dir=str(output_dir), + ) + + sr = results.session_results[0] + + rec = record_online_metrics( + metrics_path, session_index, bm_slug, task_id, sr, + "interleaved", args.seed, args.model, + all_scores, bm_scores, + ) + print(f" score={rec['score']:.2f} cum={rec['cumulative_avg_score']:.3f} " + f"steps={rec['steps']}") + + stores = SkillStore.list_stores() + store = stores.get("autoskill_interleaved_global") + if store: + print(f" skillbank: sessions={store.session_count}, " + f"skills={store.skill_count}") + store.save_checkpoint(str(output_dir / "skillstore_autoskill_interleaved_global.json")) + session_index += 1 + + print(f"\n{'=' * 70}") + print(f" Final Summary") + print(f"{'=' * 70}") + if all_scores: + print(f" Overall avg score: {sum(all_scores)/len(all_scores):.3f}") + for bm, scores in sorted(bm_scores.items()): + print(f" {bm:20s}: avg={sum(scores)/len(scores):.3f} n={len(scores)}") + + stores = SkillStore.list_stores() + for store_id, store in stores.items(): + print(f" skillbank[{store_id}]: sessions={store.session_count}, " + f"skills={store.skill_count}") + + print(f"\n Metrics: {metrics_path}") + print(f" Output: {output_dir}") + +def main(): + parser = argparse.ArgumentParser(description="Run AutoSkill test-time learning experiment") + parser.add_argument("--mode", required=True, choices=["isolated", "sequential", "interleaved"]) + parser.add_argument("--seed", type=int, required=True) + parser.add_argument("--num-tasks", type=int, default=50, help="Tasks per benchmark") + parser.add_argument("--model", default="openai/gpt-5.4") + parser.add_argument("--benchmarks", default="browsecompplus,swebench,bfcl,tau2", + help="Comma-separated benchmark slugs") + parser.add_argument("--output-dir", required=True) + parser.add_argument("--max-tokens", type=int, default=None, help="Max output tokens") + parser.add_argument("--reasoning-effort", default=None, help="Reasoning effort (low/medium/high)") + args = parser.parse_args() + run_experiment(args) + + +if __name__ == "__main__": + main() diff --git a/labs/AgentStream/exgentic/scripts/autoskill/run_experiment.sh b/labs/AgentStream/exgentic/scripts/autoskill/run_experiment.sh new file mode 100644 index 00000000..86b59c1a --- /dev/null +++ b/labs/AgentStream/exgentic/scripts/autoskill/run_experiment.sh @@ -0,0 +1,61 @@ +#!/bin/bash +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +set -e + +export OPENAI_API_BASE="" +export OPENAI_API_KEY="" + +cd "$(dirname "$0")" + +# ============================================================ +# Configuration +# ============================================================ +SEED=42 +NUM_TASKS=50 +MODEL="openai/gpt5.4" +MAX_TOKENS="default" +REASONING_EFFORT="default" +MODE="sequential" # isolated | sequential | interleaved + + +OUTPUT_BASE="./outputs" +MODEL_SHORT=$(echo $MODEL | sed 's|openai/||; s|azure/||; s|anthropic/||; s|/|_|g') +RUN_TAG="autoskill_${MODE}_s${SEED}_${MODEL_SHORT}_${MAX_TOKENS}_${REASONING_EFFORT}" + +SETTINGS_ARGS="" +[ "$MAX_TOKENS" != "default" ] && SETTINGS_ARGS="$SETTINGS_ARGS --max-tokens $MAX_TOKENS" +[ "$REASONING_EFFORT" != "default" ] && SETTINGS_ARGS="$SETTINGS_ARGS --reasoning-effort $REASONING_EFFORT" +echo "Mode: ${MODE}" +echo "Model: ${MODEL}" +echo "ModelSettings args: ${SETTINGS_ARGS:-default (no overrides)}" + +mkdir -p "$OUTPUT_BASE" + +ALL_BENCHMARKS="hle,bfcl,browsecompplus,appworld,swebench,tau2" + +if [ "$MODE" = "isolated" ]; then + for BENCH in swebench tau2 browsecompplus appworld hle bfcl; do + echo "=== Running isolated ${BENCH} ===" + uv run python run_experiment.py \ + --mode isolated --seed $SEED --num-tasks $NUM_TASKS \ + --model $MODEL $SETTINGS_ARGS \ + --benchmarks $BENCH \ + --output-dir ${OUTPUT_BASE}/${RUN_TAG}_${BENCH} \ + 2>&1 | tee ${OUTPUT_BASE}/${RUN_TAG}_${BENCH}.log + done +else + echo "=== Running ${MODE} (all benchmarks) ===" + uv run python run_experiment.py \ + --mode $MODE --seed $SEED --num-tasks $NUM_TASKS \ + --model $MODEL $SETTINGS_ARGS \ + --benchmarks $ALL_BENCHMARKS \ + --output-dir ${OUTPUT_BASE}/${RUN_TAG}_all \ + 2>&1 | tee ${OUTPUT_BASE}/${RUN_TAG}_all.log +fi + +echo "" +echo "=== All benchmarks complete ===" +echo "Run tag: ${RUN_TAG}" +echo "Outputs in: ${OUTPUT_BASE}/${RUN_TAG}_*" diff --git a/labs/AgentStream/exgentic/scripts/harness/run_experiment.py b/labs/AgentStream/exgentic/scripts/harness/run_experiment.py new file mode 100644 index 00000000..b211007c --- /dev/null +++ b/labs/AgentStream/exgentic/scripts/harness/run_experiment.py @@ -0,0 +1,386 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +import argparse +import json +import os +import sys +from collections import defaultdict +from datetime import datetime +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src")) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "utils")) + +os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + +# --- ExGentic imports --- +from exgentic.interfaces.lib.api import evaluate +from exgentic.interfaces.registry import load_agent, load_benchmark +from exgentic.agents.harness.harness_store import HarnessStore +from exgentic.core.types import ModelSettings + +from task_ordering import get_unified_task_order, group_by_benchmark + + +BENCHMARK_REGISTRY: dict[str, dict[str, Any]] = { + "browsecompplus": { + "bm_kwargs": { + "searcher_type": "faiss", + "include_get_document": True, + "eval_model_id": "openai/gpt-5.4", + }, + "agent_kwargs": {}, + }, + "swebench": { + "bm_kwargs": { + "subset": "princeton-nlp/SWE-bench_Verified", + }, + "agent_kwargs": {}, + }, + "appworld": { + "bm_kwargs": { + "subset": "test_challenge", + }, + "agent_kwargs": { + "enable_tool_shortlisting": True, + "max_selected_tools": 30, + }, + }, + "bfcl": { + "bm_kwargs": { + "subset": "multi_turn_base", + }, + "agent_kwargs": {}, + }, + "tau2": { + "bm_kwargs": { + "subset": "telecom", + "user_simulator_model": "openai/gpt-5.4", + }, + "agent_kwargs": {}, + }, + "hle": { + "bm_kwargs": { + "judge_model": "openai/gpt-5.4", + "runner": "direct", + }, + "agent_kwargs": {}, + }, +} + + +def extract_token_counts(cost_reports: dict) -> tuple[int, int]: + total_in, total_out = 0, 0 + for report in cost_reports.values(): + if isinstance(report, dict): + total_in += report.get("input_tokens", 0) + total_out += report.get("output_tokens", 0) + elif hasattr(report, "input_tokens"): + total_in += report.input_tokens + total_out += report.output_tokens + return total_in, total_out + + +def get_memory_tokens(mode: str, bm_slug: str) -> tuple[int, int]: + stores = HarnessStore.list_stores() + if mode == "isolated": + store = stores.get(f"harness_isolated_{bm_slug}") + elif mode == "sequential": + store = stores.get("harness_sequential_global") + elif mode == "interleaved": + store = stores.get("harness_interleaved_global") + else: + return 0, 0 + if store is None: + return 0, 0 + total_chars = len(store.system_prompt) + len(store.memory) + for skill in store.list_skills(): + total_chars += len(skill.description) + len(skill.body) + mem_tokens = total_chars // 4 + return mem_tokens, store.skill_count + + +def record_online_metrics( + metrics_path: Path, + session_index: int, + bm_slug: str, + task_id: str, + sr: Any, + mode: str, + seed: int, + model: str, + all_scores: list[float], + bm_scores: dict[str, list[float]], +): + score = sr.score if sr.score is not None else (1.0 if sr.success else 0.0) + all_scores.append(score) + bm_scores[bm_slug].append(score) + + input_tokens, output_tokens = extract_token_counts(sr.cost_reports) + memory_tokens, skill_count = get_memory_tokens(mode, bm_slug) + + record = { + "session_index": session_index, + "seed": seed, + "mode": mode, + "agent": "harness", + "model": model, + "benchmark_slug": bm_slug, + "task_id": task_id, + "score": score, + "cumulative_avg_score": sum(all_scores) / len(all_scores), + "benchmark_cumulative_avg_score": ( + sum(bm_scores[bm_slug]) / len(bm_scores[bm_slug]) + ), + "steps": sr.steps, + "action_count": sr.action_count, + "agent_cost": sr.agent_cost, + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "memory_tokens": memory_tokens, + "skill_count": skill_count, + "execution_time": sr.execution_time, + "status": sr.status.value if hasattr(sr.status, "value") else str(sr.status), + "timestamp": datetime.now().isoformat(), + } + + with open(metrics_path, "a", encoding="utf-8") as f: + f.write(json.dumps(record, ensure_ascii=False) + "\n") + + return record + +def run_experiment(args): + benchmarks_to_run = [s.strip() for s in args.benchmarks.split(",")] + configs = {k: BENCHMARK_REGISTRY[k] for k in benchmarks_to_run} + + settings_kwargs = {} + if args.max_tokens is not None: + settings_kwargs["max_tokens"] = args.max_tokens + if args.reasoning_effort is not None: + settings_kwargs["reasoning_effort"] = args.reasoning_effort + model_settings = ModelSettings(**settings_kwargs) + + print(f"\n{'=' * 70}") + print(f" Harness Experiment: mode={args.mode} seed={args.seed}") + print(f" model={args.model} num_tasks={args.num_tasks}") + print(f" model_settings={settings_kwargs or 'default'}") + print(f" benchmarks={benchmarks_to_run}") + print(f" output_dir={args.output_dir}") + print(f"{'=' * 70}\n") + + task_order = get_unified_task_order(configs, args.num_tasks, args.seed, args.mode) + print(f"Total tasks: {len(task_order)}") + + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + metrics_path = output_dir / "online_metrics.jsonl" + + exp_config = { + "mode": args.mode, + "seed": args.seed, + "agent": "harness", + "model": args.model, + "num_tasks": args.num_tasks, + "benchmarks": benchmarks_to_run, + "task_order": [(s, t) for s, t in task_order], + } + with open(output_dir / "experiment_config.json", "w") as f: + json.dump(exp_config, f, indent=2) + + HarnessStore.reset_all() + if args.mode == "interleaved": + _ckpt_ids = ["harness_interleaved_global"] + elif args.mode == "sequential": + _ckpt_ids = ["harness_sequential_global"] + else: + _ckpt_ids = [f"harness_isolated_{b}" for b in benchmarks_to_run] + + _restored = False + _restored_session_count = 0 + for sid in _ckpt_ids: + ckpt_path = output_dir / f"harness_{sid}.json" + if ckpt_path.exists(): + store = HarnessStore.get_or_create( + shuffle_mode=args.mode, + benchmark_id=sid.replace("harness_isolated_", "") if args.mode == "isolated" else None, + ) + store.load_checkpoint(str(ckpt_path)) + _restored_session_count = max(_restored_session_count, store.session_count) + _restored = True + if _restored: + print(f" ♻️ Restored harness store from checkpoint (session_count={_restored_session_count})") + + all_scores: list[float] = [] + bm_scores: defaultdict[str, list[float]] = defaultdict(list) + session_index = 0 + + if metrics_path.exists(): + kept_lines: list[str] = [] + with open(metrics_path, "r") as f: + for line in f: + if _restored and session_index >= _restored_session_count: + break + rec = json.loads(line) + all_scores.append(rec["score"]) + bm_scores[rec["benchmark_slug"]].append(rec["score"]) + kept_lines.append(line) + session_index += 1 + with open(metrics_path, "w") as f: + f.writelines(kept_lines) + if session_index > 0: + print(f" ♻️ Restored {session_index} metrics records (cum_avg={sum(all_scores)/len(all_scores):.3f})") + + if args.mode in ("isolated", "sequential"): + _completed_benchmarks: set[str] = set() + if _restored and session_index > 0: + _bm_counts: dict[str, int] = defaultdict(int) + with open(metrics_path, "r") as f: + for line in f: + rec = json.loads(line) + _bm_counts[rec["benchmark_slug"]] += 1 + for bm_slug, task_ids in group_by_benchmark(task_order): + if _bm_counts.get(bm_slug, 0) >= len(task_ids): + _completed_benchmarks.add(bm_slug) + if _completed_benchmarks: + print(f" ⏭️ Skipping completed benchmarks: {sorted(_completed_benchmarks)}") + + for bm_slug, task_ids in group_by_benchmark(task_order): + if bm_slug in _completed_benchmarks: + continue + + print(f"\n{'=' * 60}") + print(f" {args.mode.upper()} — {bm_slug} ({len(task_ids)} tasks)") + print(f"{'=' * 60}\n") + + bm_kwargs = configs[bm_slug]["bm_kwargs"] + agent_kwargs = configs[bm_slug].get("agent_kwargs", {}) + + benchmark = load_benchmark(bm_slug)(**bm_kwargs) + agent = load_agent("harness")( + model=args.model, + evolver_model=args.model, + shuffle_mode=args.mode, + benchmark_id=bm_slug, + embedding_model="all-MiniLM-L6-v2", + runner="direct", + model_settings=model_settings, + **agent_kwargs, + ) + + results = evaluate( + benchmark=benchmark, + agent=agent, + task_ids=task_ids, + max_workers=1, + output_dir=str(output_dir), + ) + + print(f" {bm_slug} score={results.benchmark_score}") + stores = HarnessStore.list_stores() + store_key = (f"harness_isolated_{bm_slug}" if args.mode == "isolated" + else "harness_sequential_global") + store = stores.get(store_key) + if store: + print(f" harness: sessions={store.session_count}, " + f"skills={store.skill_count}") + store.save_checkpoint(str(output_dir / f"harness_{store_key}.json")) + + for i, sr in enumerate(results.session_results): + tid = task_ids[i] if i < len(task_ids) else sr.task_id or "?" + rec = record_online_metrics( + metrics_path, session_index, bm_slug, tid, sr, + args.mode, args.seed, args.model, + all_scores, bm_scores, + ) + print(f" [{session_index}] {bm_slug}::{tid} " + f"score={rec['score']:.2f} cum={rec['cumulative_avg_score']:.3f} " + f"steps={rec['steps']}") + session_index += 1 + + elif args.mode == "interleaved": + for i, (bm_slug, task_id) in enumerate(task_order): + if _restored and i < _restored_session_count: + print(f" ⏭️ Skipping Interleaved [{i+1}/{len(task_order)}] {bm_slug}::{task_id} (cached)") + continue + + print(f"\n--- Interleaved [{i+1}/{len(task_order)}] {bm_slug}::{task_id} ---") + + bm_kwargs = configs[bm_slug]["bm_kwargs"] + agent_kwargs = configs[bm_slug].get("agent_kwargs", {}) + + benchmark = load_benchmark(bm_slug)(**bm_kwargs) + agent = load_agent("harness")( + model=args.model, + evolver_model=args.model, + shuffle_mode="interleaved", + benchmark_id=bm_slug, + embedding_model="all-MiniLM-L6-v2", + runner="direct", + model_settings=model_settings, + **agent_kwargs, + ) + + results = evaluate( + benchmark=benchmark, + agent=agent, + task_ids=[task_id], + max_workers=1, + output_dir=str(output_dir), + ) + + sr = results.session_results[0] + + rec = record_online_metrics( + metrics_path, session_index, bm_slug, task_id, sr, + "interleaved", args.seed, args.model, + all_scores, bm_scores, + ) + print(f" score={rec['score']:.2f} cum={rec['cumulative_avg_score']:.3f} " + f"steps={rec['steps']}") + + stores = HarnessStore.list_stores() + store = stores.get("harness_interleaved_global") + if store: + print(f" harness: sessions={store.session_count}, " + f"skills={store.skill_count}") + store.save_checkpoint(str(output_dir / "harness_harness_interleaved_global.json")) + session_index += 1 + + print(f"\n{'=' * 70}") + print(f" Final Summary") + print(f"{'=' * 70}") + if all_scores: + print(f" Overall avg score: {sum(all_scores)/len(all_scores):.3f}") + for bm, scores in sorted(bm_scores.items()): + print(f" {bm:20s}: avg={sum(scores)/len(scores):.3f} n={len(scores)}") + + stores = HarnessStore.list_stores() + for store_id, store in stores.items(): + print(f" harness[{store_id}]: sessions={store.session_count}, " + f"skills={store.skill_count}") + + print(f"\n Metrics: {metrics_path}") + print(f" Output: {output_dir}") + + +def main(): + parser = argparse.ArgumentParser(description="Run Harness test-time learning experiment") + parser.add_argument("--mode", required=True, choices=["isolated", "sequential", "interleaved"]) + parser.add_argument("--seed", type=int, required=True) + parser.add_argument("--num-tasks", type=int, default=50, help="Tasks per benchmark") + parser.add_argument("--model", default="openai/gpt-5.4") + parser.add_argument("--benchmarks", default="browsecompplus,swebench,bfcl,tau2", + help="Comma-separated benchmark slugs") + parser.add_argument("--output-dir", required=True) + parser.add_argument("--max-tokens", type=int, default=None, help="Max output tokens") + parser.add_argument("--reasoning-effort", default=None, help="Reasoning effort (low/medium/high)") + args = parser.parse_args() + run_experiment(args) + + +if __name__ == "__main__": + main() diff --git a/labs/AgentStream/exgentic/scripts/harness/run_experiment.sh b/labs/AgentStream/exgentic/scripts/harness/run_experiment.sh new file mode 100644 index 00000000..71ab5454 --- /dev/null +++ b/labs/AgentStream/exgentic/scripts/harness/run_experiment.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +set -e + +export OPENAI_API_BASE="" +export OPENAI_API_KEY="" + +cd "$(dirname "$0")" + +SEED=44 +NUM_TASKS=50 +MODEL="openai/gpt-5.4" +MAX_TOKENS="default" +REASONING_EFFORT="default" +MODE="sequential" # isolated | sequential | interleaved + + +OUTPUT_BASE="./outputs" +MODEL_SHORT=$(echo $MODEL | sed 's|openai/||; s|azure/||; s|/|_|g') +RUN_TAG="harness_${MODE}_s${SEED}_${MODEL_SHORT}_${MAX_TOKENS}_${REASONING_EFFORT}" + +SETTINGS_ARGS="" +[ "$MAX_TOKENS" != "default" ] && SETTINGS_ARGS="$SETTINGS_ARGS --max-tokens $MAX_TOKENS" +[ "$REASONING_EFFORT" != "default" ] && SETTINGS_ARGS="$SETTINGS_ARGS --reasoning-effort $REASONING_EFFORT" +echo "Mode: ${MODE}" +echo "ModelSettings args: ${SETTINGS_ARGS:-default (no overrides)}" + +mkdir -p "$OUTPUT_BASE" + +ALL_BENCHMARKS="hle,bfcl,browsecompplus,appworld,swebench,tau2" + +if [ "$MODE" = "isolated" ]; then + for BENCH in swebench tau2 browsecompplus appworld hle bfcl; do + echo "=== Running isolated ${BENCH} ===" + uv run python run_experiment.py \ + --mode isolated --seed $SEED --num-tasks $NUM_TASKS \ + --model $MODEL $SETTINGS_ARGS \ + --benchmarks $BENCH \ + --output-dir ${OUTPUT_BASE}/${RUN_TAG}_${BENCH} \ + 2>&1 | tee ${OUTPUT_BASE}/${RUN_TAG}_${BENCH}.log + done +else + echo "=== Running ${MODE} (all benchmarks) ===" + uv run python run_experiment.py \ + --mode $MODE --seed $SEED --num-tasks $NUM_TASKS \ + --model $MODEL $SETTINGS_ARGS \ + --benchmarks $ALL_BENCHMARKS \ + --output-dir ${OUTPUT_BASE}/${RUN_TAG}_all \ + 2>&1 | tee ${OUTPUT_BASE}/${RUN_TAG}_all.log +fi + +echo "" +echo "=== All benchmarks complete ===" +echo "Run tag: ${RUN_TAG}" +echo "Outputs in: ${OUTPUT_BASE}/${RUN_TAG}_*" diff --git a/labs/AgentStream/exgentic/scripts/litellm/run_baseline.py b/labs/AgentStream/exgentic/scripts/litellm/run_baseline.py new file mode 100644 index 00000000..4432741e --- /dev/null +++ b/labs/AgentStream/exgentic/scripts/litellm/run_baseline.py @@ -0,0 +1,238 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +from collections import defaultdict +from datetime import datetime +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src")) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "ace")) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "utils")) + +os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + +from exgentic.interfaces.lib.api import evaluate +from exgentic.interfaces.registry import load_agent, load_benchmark +from exgentic.core.types import ModelSettings + +from task_ordering import get_unified_task_order, group_by_benchmark + + +BENCHMARK_REGISTRY: dict[str, dict[str, Any]] = { + "browsecompplus": { + "bm_kwargs": { + "searcher_type": "faiss", + "include_get_document": True, + "eval_model_id": "openai/gpt-5.4", + }, + "agent_kwargs": {}, + }, + "swebench": { + "bm_kwargs": { + "subset": "princeton-nlp/SWE-bench_Verified", + }, + "agent_kwargs": {}, + }, + "appworld": { + "bm_kwargs": { + "subset": "test_challenge", + }, + "agent_kwargs": { + "enable_tool_shortlisting": True, + "max_selected_tools": 30, + }, + }, + "bfcl": { + "bm_kwargs": { + "subset": "multi_turn_base", + }, + "agent_kwargs": {}, + }, + "tau2": { + "bm_kwargs": { + "subset": "telecom", + "user_simulator_model": "openai/gpt-5.4", + }, + "agent_kwargs": {}, + }, + "hle": { + "bm_kwargs": { + "judge_model": "openai/gpt-5.4", + }, + "agent_kwargs": {}, + }, +} + +def extract_token_counts(cost_reports: dict) -> tuple[int, int]: + total_in, total_out = 0, 0 + for report in cost_reports.values(): + if isinstance(report, dict): + total_in += report.get("input_tokens", 0) + total_out += report.get("output_tokens", 0) + elif hasattr(report, "input_tokens"): + total_in += report.input_tokens + total_out += report.output_tokens + return total_in, total_out + + +def record_online_metrics( + metrics_path: Path, + session_index: int, + bm_slug: str, + task_id: str, + sr: Any, + seed: int, + model: str, + all_scores: list[float], + bm_scores: dict[str, list[float]], +): + score = sr.score if sr.score is not None else (1.0 if sr.success else 0.0) + all_scores.append(score) + bm_scores[bm_slug].append(score) + + input_tokens, output_tokens = extract_token_counts(sr.cost_reports) + + record = { + "session_index": session_index, + "seed": seed, + "mode": "baseline", + "agent": "tool_calling", + "model": model, + "benchmark_slug": bm_slug, + "task_id": task_id, + "score": score, + "cumulative_avg_score": sum(all_scores) / len(all_scores), + "benchmark_cumulative_avg_score": ( + sum(bm_scores[bm_slug]) / len(bm_scores[bm_slug]) + ), + "steps": sr.steps, + "action_count": sr.action_count, + "agent_cost": sr.agent_cost, + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "memory_tokens": 0, + "playbook_bullets": 0, + "execution_time": sr.execution_time, + "status": sr.status.value if hasattr(sr.status, "value") else str(sr.status), + "timestamp": datetime.now().isoformat(), + } + + with open(metrics_path, "a", encoding="utf-8") as f: + f.write(json.dumps(record, ensure_ascii=False) + "\n") + + return record + +def run_baseline(args): + benchmarks_to_run = [s.strip() for s in args.benchmarks.split(",")] + configs = {k: BENCHMARK_REGISTRY[k] for k in benchmarks_to_run} + + settings_kwargs = {} + if args.max_tokens is not None: + settings_kwargs["max_tokens"] = args.max_tokens + if args.reasoning_effort is not None: + settings_kwargs["reasoning_effort"] = args.reasoning_effort + model_settings = ModelSettings(**settings_kwargs) + + print(f"\n{'=' * 70}") + print(f" Baseline: tool_calling agent (no learning)") + print(f" seed={args.seed} model={args.model} num_tasks={args.num_tasks}") + print(f" model_settings={settings_kwargs or 'default'}") + print(f" benchmarks={benchmarks_to_run}") + print(f" output_dir={args.output_dir}") + print(f"{'=' * 70}\n") + + task_order = get_unified_task_order(configs, args.num_tasks, args.seed, "isolated") + print(f"Total tasks: {len(task_order)}") + + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + metrics_path = output_dir / "online_metrics.jsonl" + + exp_config = { + "mode": "baseline", + "seed": args.seed, + "agent": "tool_calling", + "model": args.model, + "num_tasks": args.num_tasks, + "benchmarks": benchmarks_to_run, + "task_order": [(s, t) for s, t in task_order], + } + with open(output_dir / "experiment_config.json", "w") as f: + json.dump(exp_config, f, indent=2) + + all_scores: list[float] = [] + bm_scores: defaultdict[str, list[float]] = defaultdict(list) + session_index = 0 + + for bm_slug, task_ids in group_by_benchmark(task_order): + print(f"\n{'=' * 60}") + print(f" BASELINE — {bm_slug} ({len(task_ids)} tasks)") + print(f"{'=' * 60}\n") + + bm_kwargs = configs[bm_slug]["bm_kwargs"] + agent_kwargs = configs[bm_slug].get("agent_kwargs", {}) + + benchmark = load_benchmark(bm_slug)(**bm_kwargs) + agent = load_agent("tool_calling")( + model=args.model, + runner="direct", + model_settings=model_settings, + allow_truncated_messages=True, + **agent_kwargs, + ) + + results = evaluate( + benchmark=benchmark, + agent=agent, + task_ids=task_ids, + max_workers=5, + output_dir=str(output_dir), + ) + + print(f" {bm_slug} score={results.benchmark_score}") + + for i, sr in enumerate(results.session_results): + tid = task_ids[i] if i < len(task_ids) else sr.task_id or "?" + rec = record_online_metrics( + metrics_path, session_index, bm_slug, tid, sr, + args.seed, args.model, all_scores, bm_scores, + ) + print(f" [{session_index}] {bm_slug}::{tid} " + f"score={rec['score']:.2f} cum={rec['cumulative_avg_score']:.3f} " + f"steps={rec['steps']}") + session_index += 1 + + print(f"\n{'=' * 70}") + print(f" Baseline Summary") + print(f"{'=' * 70}") + print(f" Overall avg score: {sum(all_scores)/len(all_scores):.3f}") + for bm, scores in sorted(bm_scores.items()): + print(f" {bm:20s}: avg={sum(scores)/len(scores):.3f} n={len(scores)}") + print(f"\n Metrics: {metrics_path}") + print(f" Output: {output_dir}") + + +def main(): + parser = argparse.ArgumentParser(description="Run baseline (tool_calling agent, no learning)") + parser.add_argument("--seed", type=int, required=True) + parser.add_argument("--num-tasks", type=int, default=50, help="Tasks per benchmark") + parser.add_argument("--model", default="openai/gpt-5.4") + parser.add_argument("--benchmarks", default="browsecompplus,swebench,bfcl,tau2", + help="Comma-separated benchmark slugs") + parser.add_argument("--output-dir", required=True) + parser.add_argument("--max-tokens", type=int, default=None, help="Max output tokens") + parser.add_argument("--reasoning-effort", default=None, help="Reasoning effort (low/medium/high)") + args = parser.parse_args() + run_baseline(args) + + +if __name__ == "__main__": + main() diff --git a/labs/AgentStream/exgentic/scripts/litellm/run_baseline.sh b/labs/AgentStream/exgentic/scripts/litellm/run_baseline.sh new file mode 100644 index 00000000..382fa7e2 --- /dev/null +++ b/labs/AgentStream/exgentic/scripts/litellm/run_baseline.sh @@ -0,0 +1,84 @@ +#!/bin/bash +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +set -e + +export OPENAI_API_BASE="" +export OPENAI_API_KEY="" + +cd "$(dirname "$0")" + +SEED=42 +NUM_TASKS=50 +MODEL="openai/gpt-5.4" +MAX_TOKENS="default" +REASONING_EFFORT="default" + +OUTPUT_BASE="./outputs" +MODEL_SHORT=$(echo $MODEL | sed 's|openai/||; s|azure/||; s|/|_|g') +RUN_TAG="baseline_s${SEED}_${MODEL_SHORT}_${MAX_TOKENS}_${REASONING_EFFORT}" + +SETTINGS_ARGS="" +[ "$MAX_TOKENS" != "default" ] && SETTINGS_ARGS="$SETTINGS_ARGS --max-tokens $MAX_TOKENS" +[ "$REASONING_EFFORT" != "default" ] && SETTINGS_ARGS="$SETTINGS_ARGS --reasoning-effort $REASONING_EFFORT" +echo "ModelSettings args: ${SETTINGS_ARGS:-default (no overrides)}" + +# HLE +echo "=== Running HLE ===" +uv run python run_baseline.py \ + --seed $SEED --num-tasks $NUM_TASKS \ + --model $MODEL $SETTINGS_ARGS \ + --benchmarks hle \ + --output-dir ${OUTPUT_BASE}/${RUN_TAG}_hle \ + 2>&1 | tee ${OUTPUT_BASE}/${RUN_TAG}_hle.log + +# BFCL +echo "=== Running BFCL ===" +uv run python run_baseline.py \ + --seed $SEED --num-tasks $NUM_TASKS \ + --model $MODEL $SETTINGS_ARGS \ + --benchmarks bfcl \ + --output-dir ${OUTPUT_BASE}/${RUN_TAG}_bfcl \ + 2>&1 | tee ${OUTPUT_BASE}/${RUN_TAG}_bfcl.log + +# Tau2 +echo "=== Running Tau2 ===" +uv run python run_baseline.py \ + --seed $SEED --num-tasks $NUM_TASKS \ + --model $MODEL $SETTINGS_ARGS \ + --benchmarks tau2 \ + --output-dir ${OUTPUT_BASE}/${RUN_TAG}_tau2 \ + 2>&1 | tee ${OUTPUT_BASE}/${RUN_TAG}_tau2.log + +# BrowseCompPlus +echo "=== Running BrowseCompPlus ===" +uv run python run_baseline.py \ + --seed $SEED --num-tasks $NUM_TASKS \ + --model $MODEL $SETTINGS_ARGS \ + --benchmarks browsecompplus \ + --output-dir ${OUTPUT_BASE}/${RUN_TAG}_browsecompplus \ + 2>&1 | tee ${OUTPUT_BASE}/${RUN_TAG}_browsecompplus.log + +# AppWorld +echo "=== Running AppWorld ===" +uv run python run_baseline.py \ + --seed $SEED --num-tasks $NUM_TASKS \ + --model $MODEL $SETTINGS_ARGS \ + --benchmarks appworld \ + --output-dir ${OUTPUT_BASE}/${RUN_TAG}_appworld \ + 2>&1 | tee ${OUTPUT_BASE}/${RUN_TAG}_appworld.log + +# SWE-bench +echo "=== Running SWE-bench ===" +uv run python run_baseline.py \ + --seed $SEED --num-tasks $NUM_TASKS \ + --model $MODEL $SETTINGS_ARGS \ + --benchmarks swebench \ + --output-dir ${OUTPUT_BASE}/${RUN_TAG}_swebench \ + 2>&1 | tee ${OUTPUT_BASE}/${RUN_TAG}_swebench.log + +echo "" +echo "=== All benchmarks complete ===" +echo "Run tag: ${RUN_TAG}" +echo "Outputs in: ${OUTPUT_BASE}/${RUN_TAG}_*" diff --git a/labs/AgentStream/exgentic/scripts/reasoning_bank/run_experiment.py b/labs/AgentStream/exgentic/scripts/reasoning_bank/run_experiment.py new file mode 100644 index 00000000..6c49a725 --- /dev/null +++ b/labs/AgentStream/exgentic/scripts/reasoning_bank/run_experiment.py @@ -0,0 +1,403 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +import argparse +import json +import os +import sys +from collections import defaultdict +from datetime import datetime +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src")) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "utils")) + +os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + +# --- ExGentic imports --- +from exgentic.interfaces.lib.api import evaluate +from exgentic.interfaces.registry import load_agent, load_benchmark +from exgentic.agents.reasoning_bank.rb_store import ReasoningBankStore +from exgentic.core.types import ModelSettings + +from task_ordering import get_unified_task_order, group_by_benchmark + +BENCHMARK_REGISTRY: dict[str, dict[str, Any]] = { + "browsecompplus": { + "bm_kwargs": { + "searcher_type": "faiss", + "include_get_document": True, + "eval_model_id": "openai/gpt-5.4", + }, + "agent_kwargs": {}, + }, + "swebench": { + "bm_kwargs": { + "subset": "princeton-nlp/SWE-bench_Verified", + }, + "agent_kwargs": {}, + }, + "appworld": { + "bm_kwargs": { + "subset": "test_challenge", + }, + "agent_kwargs": { + "enable_tool_shortlisting": True, + "max_selected_tools": 30, + }, + }, + "bfcl": { + "bm_kwargs": { + "subset": "multi_turn_base", + }, + "agent_kwargs": {}, + }, + "tau2": { + "bm_kwargs": { + "subset": "telecom", + "user_simulator_model": "openai/gpt-5.4", + }, + "agent_kwargs": {}, + }, + "hle": { + "bm_kwargs": { + "judge_model": "openai/gpt-5.4", + "runner": "direct", + }, + "agent_kwargs": {}, + }, +} + +def extract_token_counts(cost_reports: dict) -> tuple[int, int]: + total_in, total_out = 0, 0 + for report in cost_reports.values(): + if isinstance(report, dict): + total_in += report.get("input_tokens", 0) + total_out += report.get("output_tokens", 0) + elif hasattr(report, "input_tokens"): + total_in += report.input_tokens + total_out += report.output_tokens + return total_in, total_out + + +def get_memory_stats(mode: str, bm_slug: str) -> tuple[int, int]: + stores = ReasoningBankStore.list_stores() + if mode == "isolated": + store = stores.get(f"rb_isolated_{bm_slug}") + elif mode == "sequential": + store = stores.get("rb_sequential_global") + elif mode == "interleaved": + store = stores.get("rb_interleaved_global") + else: + return 0, 0 + if store is None: + return 0, 0 + entries = store.get_entries() + total_chars = sum(len("\n\n".join(e.memory_items)) for e in entries) + mem_tokens = total_chars // 4 + return len(entries), mem_tokens + + + +def record_online_metrics( + metrics_path: Path, + session_index: int, + bm_slug: str, + task_id: str, + sr: Any, + mode: str, + seed: int, + model: str, + all_scores: list[float], + bm_scores: dict[str, list[float]], +): + score = sr.score if sr.score is not None else (1.0 if sr.success else 0.0) + all_scores.append(score) + bm_scores[bm_slug].append(score) + + input_tokens, output_tokens = extract_token_counts(sr.cost_reports) + memory_entries, memory_tokens = get_memory_stats(mode, bm_slug) + + record = { + "session_index": session_index, + "seed": seed, + "mode": mode, + "agent": "reasoning_bank", + "model": model, + "benchmark_slug": bm_slug, + "task_id": task_id, + "score": score, + "cumulative_avg_score": sum(all_scores) / len(all_scores), + "benchmark_cumulative_avg_score": ( + sum(bm_scores[bm_slug]) / len(bm_scores[bm_slug]) + ), + "steps": sr.steps, + "action_count": sr.action_count, + "agent_cost": sr.agent_cost, + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "memory_entries": memory_entries, + "memory_tokens": memory_tokens, + "execution_time": sr.execution_time, + "status": sr.status.value if hasattr(sr.status, "value") else str(sr.status), + "timestamp": datetime.now().isoformat(), + } + + with open(metrics_path, "a", encoding="utf-8") as f: + f.write(json.dumps(record, ensure_ascii=False) + "\n") + + return record + + +def run_experiment(args): + benchmarks_to_run = [s.strip() for s in args.benchmarks.split(",")] + configs = {k: BENCHMARK_REGISTRY[k] for k in benchmarks_to_run} + + settings_kwargs = {} + if args.max_tokens is not None: + settings_kwargs["max_tokens"] = args.max_tokens + if args.reasoning_effort is not None: + settings_kwargs["reasoning_effort"] = args.reasoning_effort + model_settings = ModelSettings(**settings_kwargs) + + print(f"\n{'=' * 70}") + print(f" ReasoningBank Experiment: mode={args.mode} seed={args.seed}") + print(f" model={args.model} num_tasks={args.num_tasks}") + print(f" embedding_model={args.embedding_model}") + print(f" top_k_memories={args.top_k_memories} max_memory_items={args.max_memory_items}") + print(f" model_settings={settings_kwargs or 'default'}") + print(f" benchmarks={benchmarks_to_run}") + print(f" output_dir={args.output_dir}") + print(f"{'=' * 70}\n") + + task_order = get_unified_task_order(configs, args.num_tasks, args.seed, args.mode) + print(f"Total tasks: {len(task_order)}") + + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + metrics_path = output_dir / "online_metrics.jsonl" + + exp_config = { + "mode": args.mode, + "seed": args.seed, + "agent": "reasoning_bank", + "model": args.model, + "embedding_model": args.embedding_model, + "top_k_memories": args.top_k_memories, + "max_memory_items": args.max_memory_items, + "num_tasks": args.num_tasks, + "benchmarks": benchmarks_to_run, + "task_order": [(s, t) for s, t in task_order], + } + with open(output_dir / "experiment_config.json", "w") as f: + json.dump(exp_config, f, indent=2) + + ReasoningBankStore.reset_all() + if args.mode == "interleaved": + _ckpt_ids = ["rb_interleaved_global"] + elif args.mode == "sequential": + _ckpt_ids = ["rb_sequential_global"] + else: + _ckpt_ids = [f"rb_isolated_{b}" for b in benchmarks_to_run] + + _restored = False + _restored_session_count = 0 + for sid in _ckpt_ids: + ckpt_path = output_dir / f"store_{sid}.json" + if ckpt_path.exists(): + store = ReasoningBankStore.get_or_create( + shuffle_mode=args.mode, + benchmark_id=sid.replace("rb_isolated_", "") if args.mode == "isolated" else None, + ) + store.load_checkpoint(str(ckpt_path)) + _restored_session_count = max(_restored_session_count, store.session_count) + _restored = True + if _restored: + print(f" Restored store from checkpoint (session_count={_restored_session_count})") + + all_scores: list[float] = [] + bm_scores: defaultdict[str, list[float]] = defaultdict(list) + session_index = 0 + + if metrics_path.exists(): + kept_lines: list[str] = [] + with open(metrics_path, "r") as f: + for line in f: + if _restored and session_index >= _restored_session_count: + break + rec = json.loads(line) + all_scores.append(rec["score"]) + bm_scores[rec["benchmark_slug"]].append(rec["score"]) + kept_lines.append(line) + session_index += 1 + with open(metrics_path, "w") as f: + f.writelines(kept_lines) + if session_index > 0: + print(f" Restored {session_index} metrics records (cum_avg={sum(all_scores)/len(all_scores):.3f})") + + if args.mode in ("isolated", "sequential"): + _completed_benchmarks: set[str] = set() + if _restored and session_index > 0: + _bm_counts: dict[str, int] = defaultdict(int) + with open(metrics_path, "r") as f: + for line in f: + rec = json.loads(line) + _bm_counts[rec["benchmark_slug"]] += 1 + for bm_slug, task_ids in group_by_benchmark(task_order): + if _bm_counts.get(bm_slug, 0) >= len(task_ids): + _completed_benchmarks.add(bm_slug) + if _completed_benchmarks: + print(f" Skipping completed benchmarks: {sorted(_completed_benchmarks)}") + + for bm_slug, task_ids in group_by_benchmark(task_order): + if bm_slug in _completed_benchmarks: + continue + + print(f"\n{'=' * 60}") + print(f" {args.mode.upper()} -- {bm_slug} ({len(task_ids)} tasks)") + print(f"{'=' * 60}\n") + + bm_kwargs = configs[bm_slug]["bm_kwargs"] + agent_kwargs = configs[bm_slug].get("agent_kwargs", {}) + + benchmark = load_benchmark(bm_slug)(**bm_kwargs) + agent = load_agent("reasoning_bank")( + model=args.model, + memory_model=args.model, + eval_model=args.model, + embedding_model=args.embedding_model, + top_k_memories=args.top_k_memories, + max_memory_items=args.max_memory_items, + shuffle_mode=args.mode, + benchmark_id=bm_slug, + runner="direct", + model_settings=model_settings, + **agent_kwargs, + ) + + results = evaluate( + benchmark=benchmark, + agent=agent, + task_ids=task_ids, + max_workers=1, + output_dir=str(output_dir), + ) + + print(f" {bm_slug} score={results.benchmark_score}") + + stores = ReasoningBankStore.list_stores() + store_key = (f"rb_isolated_{bm_slug}" if args.mode == "isolated" + else "rb_sequential_global") + store = stores.get(store_key) + if store: + print(f" store: sessions={store.session_count}, " + f"entries={store.entry_count}") + store.save_checkpoint(str(output_dir / f"store_{store_key}.json")) + + for i, sr in enumerate(results.session_results): + tid = task_ids[i] if i < len(task_ids) else sr.task_id or "?" + rec = record_online_metrics( + metrics_path, session_index, bm_slug, tid, sr, + args.mode, args.seed, args.model, + all_scores, bm_scores, + ) + print(f" [{session_index}] {bm_slug}::{tid} " + f"score={rec['score']:.2f} cum={rec['cumulative_avg_score']:.3f} " + f"steps={rec['steps']}") + session_index += 1 + + elif args.mode == "interleaved": + for i, (bm_slug, task_id) in enumerate(task_order): + if _restored and i < _restored_session_count: + print(f" Skipping Interleaved [{i+1}/{len(task_order)}] {bm_slug}::{task_id} (cached)") + continue + + print(f"\n--- Interleaved [{i+1}/{len(task_order)}] {bm_slug}::{task_id} ---") + + bm_kwargs = configs[bm_slug]["bm_kwargs"] + agent_kwargs = configs[bm_slug].get("agent_kwargs", {}) + + benchmark = load_benchmark(bm_slug)(**bm_kwargs) + agent = load_agent("reasoning_bank")( + model=args.model, + memory_model=args.model, + eval_model=args.model, + embedding_model=args.embedding_model, + top_k_memories=args.top_k_memories, + max_memory_items=args.max_memory_items, + shuffle_mode="interleaved", + benchmark_id=bm_slug, + runner="direct", + model_settings=model_settings, + **agent_kwargs, + ) + + results = evaluate( + benchmark=benchmark, + agent=agent, + task_ids=[task_id], + max_workers=1, + output_dir=str(output_dir), + ) + + sr = results.session_results[0] + + rec = record_online_metrics( + metrics_path, session_index, bm_slug, task_id, sr, + "interleaved", args.seed, args.model, + all_scores, bm_scores, + ) + print(f" score={rec['score']:.2f} cum={rec['cumulative_avg_score']:.3f} " + f"steps={rec['steps']}") + + stores = ReasoningBankStore.list_stores() + store = stores.get("rb_interleaved_global") + if store: + print(f" store: sessions={store.session_count}, " + f"entries={store.entry_count}") + store.save_checkpoint(str(output_dir / "store_rb_interleaved_global.json")) + session_index += 1 + + print(f"\n{'=' * 70}") + print(f" Final Summary") + print(f"{'=' * 70}") + if all_scores: + print(f" Overall avg score: {sum(all_scores)/len(all_scores):.3f}") + for bm, scores in sorted(bm_scores.items()): + print(f" {bm:20s}: avg={sum(scores)/len(scores):.3f} n={len(scores)}") + + stores = ReasoningBankStore.list_stores() + for store_id, store in stores.items(): + print(f" store[{store_id}]: sessions={store.session_count}, " + f"entries={store.entry_count}") + + print(f"\n Metrics: {metrics_path}") + print(f" Output: {output_dir}") + +def main(): + parser = argparse.ArgumentParser(description="Run ReasoningBank test-time learning experiment") + parser.add_argument("--mode", required=True, choices=["isolated", "sequential", "interleaved"]) + parser.add_argument("--seed", type=int, required=True) + parser.add_argument("--num-tasks", type=int, default=50, help="Tasks per benchmark") + parser.add_argument("--model", default="openai/gpt-5.4") + parser.add_argument("--embedding-model", default="all-MiniLM-L6-v2", + help="Local SentenceTransformer model for memory retrieval") + parser.add_argument("--benchmarks", default="browsecompplus,swebench,bfcl,tau2", + help="Comma-separated benchmark slugs") + parser.add_argument("--output-dir", required=True) + parser.add_argument("--max-tokens", type=int, default=None, help="Max output tokens") + parser.add_argument("--reasoning-effort", default=None, help="Reasoning effort (low/medium/high)") + # ReasoningBank specific hyperparameters + parser.add_argument("--top-k-memories", type=int, default=1, + help="Number of memories to retrieve per query") + parser.add_argument("--max-memory-items", type=int, default=3, + help="Max memory items per induction") + args = parser.parse_args() + run_experiment(args) + + +if __name__ == "__main__": + main() diff --git a/labs/AgentStream/exgentic/scripts/reasoning_bank/run_experiment.sh b/labs/AgentStream/exgentic/scripts/reasoning_bank/run_experiment.sh new file mode 100644 index 00000000..0ebab8a9 --- /dev/null +++ b/labs/AgentStream/exgentic/scripts/reasoning_bank/run_experiment.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +set -e + +export OPENAI_API_BASE="" +export OPENAI_API_KEY="" + + +export CUDA_VISIBLE_DEVICES=4 + +cd "$(dirname "$0")" + +SEED=44 +NUM_TASKS=50 +MODEL="openai/gpt-5.4" +MAX_TOKENS="default" +REASONING_EFFORT="default" +MODE="interleaved" # isolated | sequential | interleaved + +# ReasoningBank specific hyperparameters +TOP_K_MEMORIES=1 +MAX_MEMORY_ITEMS=3 +EMBEDDING_MODEL="all-MiniLM-L6-v2" + +OUTPUT_BASE="./outputs" +MODEL_SHORT=$(echo $MODEL | sed 's|openai/||; s|azure/||; s|/|_|g') +RUN_TAG="rb_${MODE}_s${SEED}_${MODEL_SHORT}_${MAX_TOKENS}_${REASONING_EFFORT}" + +SETTINGS_ARGS="" +[ "$MAX_TOKENS" != "default" ] && SETTINGS_ARGS="$SETTINGS_ARGS --max-tokens $MAX_TOKENS" +[ "$REASONING_EFFORT" != "default" ] && SETTINGS_ARGS="$SETTINGS_ARGS --reasoning-effort $REASONING_EFFORT" +echo "Mode: ${MODE}" +echo "ModelSettings args: ${SETTINGS_ARGS:-default (no overrides)}" +echo "ReasoningBank: top_k=${TOP_K_MEMORIES} max_items=${MAX_MEMORY_ITEMS} embedding=${EMBEDDING_MODEL}" + +mkdir -p "$OUTPUT_BASE" + +ALL_BENCHMARKS="hle,bfcl,browsecompplus,appworld,swebench,tau2" +if [ "$MODE" = "isolated" ]; then + for BENCH in swebench tau2 browsecompplus appworld hle bfcl; do + echo "=== Running isolated ${BENCH} ===" + uv run python run_experiment.py \ + --mode isolated --seed $SEED --num-tasks $NUM_TASKS \ + --model $MODEL \ + --embedding-model $EMBEDDING_MODEL \ + --top-k-memories $TOP_K_MEMORIES --max-memory-items $MAX_MEMORY_ITEMS \ + $SETTINGS_ARGS \ + --benchmarks $BENCH \ + --output-dir ${OUTPUT_BASE}/${RUN_TAG}_${BENCH} \ + 2>&1 | tee ${OUTPUT_BASE}/${RUN_TAG}_${BENCH}.log + done +else + echo "=== Running ${MODE} (all benchmarks) ===" + uv run python run_experiment.py \ + --mode $MODE --seed $SEED --num-tasks $NUM_TASKS \ + --model $MODEL \ + --embedding-model $EMBEDDING_MODEL \ + --top-k-memories $TOP_K_MEMORIES --max-memory-items $MAX_MEMORY_ITEMS \ + $SETTINGS_ARGS \ + --benchmarks $ALL_BENCHMARKS \ + --output-dir ${OUTPUT_BASE}/${RUN_TAG}_all \ + 2>&1 | tee ${OUTPUT_BASE}/${RUN_TAG}_all.log +fi + +echo "" +echo "=== All benchmarks complete ===" +echo "Run tag: ${RUN_TAG}" +echo "Outputs in: ${OUTPUT_BASE}/${RUN_TAG}_*" diff --git a/labs/AgentStream/exgentic/scripts/utils/task_ordering.py b/labs/AgentStream/exgentic/scripts/utils/task_ordering.py new file mode 100644 index 00000000..6d944a9c --- /dev/null +++ b/labs/AgentStream/exgentic/scripts/utils/task_ordering.py @@ -0,0 +1,210 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +""" +Seed-controlled task selection and ordering for evaluation experiments. + +Guarantees: + - Same seed → same task set for every benchmark, regardless of mode. + - Within-benchmark task order is identical across isolated / sequential / interleaved. + - Interleaved only interleaves *between* benchmarks; within-benchmark order is preserved. +""" + +from __future__ import annotations + +import hashlib +import random +import sys +from collections import deque +from pathlib import Path +from typing import Any + +# Allow importing exgentic from the repo source tree +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src")) + +from exgentic.interfaces.registry import load_benchmark + + +# ────────────────────────────────────────────────────────────── +# Public API +# ────────────────────────────────────────────────────────────── + +def get_unified_task_order( + benchmark_configs: dict[str, dict[str, Any]], + num_tasks_per_benchmark: int, + seed: int, + mode: str, +) -> list[tuple[str, str]]: + """Return a deterministic, mode-aware task ordering. + + Parameters + ---------- + benchmark_configs : dict + ``{slug: {"bm_kwargs": {...}, "agent_kwargs": {...}}}`` + num_tasks_per_benchmark : int + How many tasks to select from each benchmark (e.g. 50). + seed : int + Ordering seed. Controls within-benchmark task order and + interleaved interleaving. Task *selection* is always fixed at + seed=42 so all experiments use the same task set. + mode : str + ``"isolated"`` | ``"sequential"`` | ``"interleaved"``. + + Returns + ------- + list of (benchmark_slug, task_id) + Ordered task sequence. For isolated/sequential the tasks are grouped + by benchmark (sorted alphabetically by slug). For interleaved the + tasks are interleaved across benchmarks while preserving + within-benchmark order. + """ + # Always use seed=42 for task SELECTION (which tasks to pick), + # use the provided seed only for ORDERING (task sequence). + _SELECTION_SEED = 42 + per_bm_tasks = _select_tasks(benchmark_configs, num_tasks_per_benchmark, _SELECTION_SEED) + + # Re-shuffle within-benchmark order using the provided seed + if seed != _SELECTION_SEED: + for slug in per_bm_tasks: + order_seed = _derive_seed(seed, slug) + rng = random.Random(order_seed) + rng.shuffle(per_bm_tasks[slug]) + + if mode in ("isolated", "sequential"): + result: list[tuple[str, str]] = [] + for slug in sorted(per_bm_tasks): + for tid in per_bm_tasks[slug]: + result.append((slug, tid)) + return result + + if mode == "interleaved": + return _interleave_preserving_order(per_bm_tasks, seed) + + raise ValueError(f"Unknown mode: {mode!r}") + + +def select_tasks_only( + benchmark_configs: dict[str, dict[str, Any]], + num_tasks_per_benchmark: int, + seed: int, +) -> dict[str, list[str]]: + """Return the selected tasks per benchmark (no ordering applied).""" + return _select_tasks(benchmark_configs, num_tasks_per_benchmark, seed) + + +# ────────────────────────────────────────────────────────────── +# Internals +# ────────────────────────────────────────────────────────────── + +def _select_tasks( + benchmark_configs: dict[str, dict[str, Any]], + num_tasks: int, + seed: int, +) -> dict[str, list[str]]: + """Select *num_tasks* tasks per benchmark using per-benchmark derived seeds.""" + per_bm: dict[str, list[str]] = {} + for slug in sorted(benchmark_configs): + bm_kwargs = benchmark_configs[slug].get("bm_kwargs", {}) + bm = load_benchmark(slug)(**bm_kwargs) + evaluator = bm.get_evaluator() + try: + all_ids = [str(t) for t in evaluator.list_tasks()] + finally: + try: + evaluator.close() + except Exception: + pass + bm.close() + + bm_seed = _derive_seed(seed, slug) + rng = random.Random(bm_seed) + rng.shuffle(all_ids) + per_bm[slug] = all_ids[:num_tasks] + return per_bm + + +def _interleave_preserving_order( + per_bm_tasks: dict[str, list[str]], + seed: int, +) -> list[tuple[str, str]]: + """Interleave tasks across benchmarks, preserving within-benchmark order. + + At each step, randomly pick a non-empty benchmark queue and pop + its next task. This ensures the relative order within each + benchmark is the same as in isolated/sequential mode. + """ + queues = {slug: deque(tasks) for slug, tasks in per_bm_tasks.items()} + rng = random.Random(seed) + result: list[tuple[str, str]] = [] + while any(queues.values()): + available = sorted(s for s, q in queues.items() if q) + slug = rng.choice(available) + result.append((slug, queues[slug].popleft())) + return result + + +def _derive_seed(master_seed: int, slug: str) -> int: + """Derive a deterministic per-benchmark seed from master seed + slug.""" + h = hashlib.md5(f"{master_seed}_{slug}".encode()).hexdigest() + return int(h, 16) % (2**31) + + +# ────────────────────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────────────────────── + +def group_by_benchmark( + task_order: list[tuple[str, str]], +) -> list[tuple[str, list[str]]]: + """Group a task order list into (slug, [task_ids]) preserving order.""" + groups: list[tuple[str, list[str]]] = [] + current_slug: str | None = None + current_ids: list[str] = [] + for slug, tid in task_order: + if slug != current_slug: + if current_slug is not None: + groups.append((current_slug, current_ids)) + current_slug = slug + current_ids = [tid] + else: + current_ids.append(tid) + if current_slug is not None: + groups.append((current_slug, current_ids)) + return groups + + +# ────────────────────────────────────────────────────────────── +# Self-test +# ────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + # Quick sanity check without requiring benchmark data + print("=== task_ordering.py self-test ===\n") + + # Simulate with fake data + fake_per_bm = { + "bfcl": ["b1", "b2", "b3", "b4", "b5"], + "tau2": ["t1", "t2", "t3", "t4", "t5"], + "browsecompplus": ["c1", "c2", "c3", "c4", "c5"], + } + + # Test interleave preserving order + interleaved = _interleave_preserving_order(fake_per_bm, seed=42) + print("Interleaved interleave (seed=42):") + for slug, tid in interleaved: + print(f" {slug}: {tid}") + + # Verify within-benchmark order is preserved + for slug in fake_per_bm: + original = fake_per_bm[slug] + fused = [tid for s, tid in interleaved if s == slug] + assert fused == original, f"{slug}: order changed! {original} → {fused}" + print(f" ✓ {slug} order preserved: {fused}") + + # Verify different seeds produce different interleaving + interleaved2 = _interleave_preserving_order(fake_per_bm, seed=123) + order1 = [(s, t) for s, t in interleaved] + order2 = [(s, t) for s, t in interleaved2] + print(f"\n seed=42 vs seed=123 differ: {order1 != order2}") + + print("\nAll checks passed.") diff --git a/labs/AgentStream/exgentic/src/exgentic/__init__.py b/labs/AgentStream/exgentic/src/exgentic/__init__.py new file mode 100644 index 00000000..285aa3dd --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/__init__.py @@ -0,0 +1,86 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from importlib import import_module +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as package_version +from typing import Any + +try: + from ._version import version as __version__ +except ImportError: + try: + __version__ = package_version("exgentic") + except PackageNotFoundError: + __version__ = "0+unknown" + +from .environment.manager import EnvironmentManager, EnvType +from .interfaces.registry import get_agent_entries, get_benchmark_entries + +_API_EXPORTS = { + "aggregate", + "evaluate", + "execute", + "list_agents", + "list_benchmarks", + "list_subsets", + "list_tasks", + "preview", + "results", + "status", +} + +__all__ = [ + "__version__", + "EnvironmentManager", + "EnvType", + "aggregate", + "evaluate", + "execute", + "list_agents", + "list_benchmarks", + "list_subsets", + "list_tasks", + "preview", + "results", + "status", +] + + +def _find_component_export(name: str): + matches = [ + entry + for entries in (get_benchmark_entries(), get_agent_entries()) + for entry in entries.values() + if entry.attr == name + ] + if not matches: + return None + if len(matches) > 1: + slugs = ", ".join(sorted(entry.slug_name for entry in matches)) + raise AttributeError(f"Ambiguous exgentic export '{name}' found in registry entries: {slugs}.") + return matches[0] + + +def __getattr__(name: str) -> Any: + if name in _API_EXPORTS: + value = getattr(import_module(".interfaces.lib.api", __name__), name) + globals()[name] = value + return value + + entry = _find_component_export(name) + if entry is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = entry.load() + globals()[name] = value + return value + + +def __dir__() -> list[str]: + # Keep dir() side-effect free. + # Some introspection libraries (e.g. freezegun) iterate over dir(module) + # and then call getattr() for each name. Exposing lazy registry exports here + # can trigger expensive imports during unrelated initialization paths. + return sorted(set(globals()) | _API_EXPORTS) diff --git a/labs/AgentStream/exgentic/src/exgentic/adapters/__init__.py b/labs/AgentStream/exgentic/src/exgentic/adapters/__init__.py new file mode 100644 index 00000000..367fec41 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/adapters/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. diff --git a/labs/AgentStream/exgentic/src/exgentic/adapters/actions/__init__.py b/labs/AgentStream/exgentic/src/exgentic/adapters/actions/__init__.py new file mode 100644 index 00000000..ed18cc54 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/adapters/actions/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +__all__ = [] diff --git a/labs/AgentStream/exgentic/src/exgentic/adapters/actions/chat.py b/labs/AgentStream/exgentic/src/exgentic/adapters/actions/chat.py new file mode 100644 index 00000000..0e1f5d86 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/adapters/actions/chat.py @@ -0,0 +1,105 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Chat/tool-call helpers for translating between Exgentic actions and chat payloads.""" +from __future__ import annotations + +from typing import Any, Optional + +from pydantic import BaseModel + +from ...core.actions import extract_argument +from ...core.types import ( + MessageObservation, + MessagePayload, + MultiObservation, + SingleAction, + SingleObservation, +) + + +class ChatActionContext: + """Helper to map actions to chat content/tool calls and back to observations.""" + + def __init__(self) -> None: + self.message_actions: dict[str, SingleAction] = {} + self.tool_actions: dict[str, SingleAction] = {} + + @staticmethod + def action_to_tool_call_payload(action: SingleAction) -> dict[str, Any]: + arguments: Any = action.arguments + if isinstance(arguments, str): + arguments = {"error_parsing": arguments} + elif isinstance(arguments, BaseModel): + arguments = arguments.model_dump() + return {"name": action.name, "arguments": arguments, "id": action.id} + + def actions_to_chat_components(self, actions: list[SingleAction]) -> tuple[Optional[str], list[dict[str, Any]]]: + content: Optional[str] = None + tool_calls: list[dict[str, Any]] = [] + self.message_actions = {} + self.tool_actions = {} + + for act in actions: + if act.name == "message": + self.message_actions[act.id] = act + msg_val = extract_argument(act.arguments, "content", None) + if msg_val is None: + try: + msg_val = str(act.arguments) + except Exception: + msg_val = None + if msg_val is not None: + if content is None: + content = "" + content += str(msg_val) + continue + + self.tool_actions[act.id] = act + tool_calls.append(self.action_to_tool_call_payload(act)) + + return content, tool_calls + + def actions_to_assistant_message(self, actions: list[SingleAction]) -> dict[str, Any]: + """Convert actions into an assistant message dict with content and tool_calls.""" + content, tool_calls = self.actions_to_chat_components(actions) + message: dict[str, Any] = {"role": "assistant"} + if content is not None: + message["content"] = content + if tool_calls: + message["tool_calls"] = tool_calls + return message + + def message_to_observation(self, message: Any) -> SingleObservation | MultiObservation: + # Support a list of messages (e.g., multiple tool responses) + if isinstance(message, list): + items = [self.message_to_observation(m) for m in message] + flat: list[SingleObservation] = [] + for obs in items: + if isinstance(obs, MultiObservation): + flat.extend(obs.observations) + else: + flat.append(obs) + return MultiObservation(observations=flat) + + if isinstance(message, dict): + role = message.get("role") + if role == "user": + acts = list(self.message_actions.values()) + content = message.get("content") or "" + payload = MessagePayload(sender="user", message=content) + return MessageObservation(invoking_actions=acts, result=payload) + if role == "tool": + act = self.tool_actions.get(str(message.get("tool_call_id"))) + return SingleObservation( + invoking_actions=([act] if act else []), + result=message.get("content"), + ) + if "id" in message and "content" in message: + act = self.tool_actions.get(str(message.get("id"))) + return SingleObservation( + invoking_actions=([act] if act else []), + result=message.get("content"), + ) + + return SingleObservation(invoking_actions=[], result=str(message)) diff --git a/labs/AgentStream/exgentic/src/exgentic/adapters/actions/functions.py b/labs/AgentStream/exgentic/src/exgentic/adapters/actions/functions.py new file mode 100644 index 00000000..f730d0bb --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/adapters/actions/functions.py @@ -0,0 +1,94 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import inspect +from typing import Any, Callable + +from pydantic import BaseModel +from pydantic_core import PydanticUndefined + +from ...core.actions import build_action +from ...core.types import ActionType, MultiObservation, SingleAction, SingleObservation + + +def action_type_to_function( + action_type: "ActionType", internal_function: Callable[["SingleAction"], Any] +) -> Callable[..., Any]: + def function(*args, **kwargs: Any) -> Any: + all_kwargs = bind_arguments(cls=action_type.arguments, args=args, kwargs=kwargs) + action = build_action(action_type, all_kwargs) + observation = internal_function(action) + if observation is None: + return None + if isinstance(observation, SingleObservation): + return observation.result + if isinstance(observation, MultiObservation): + return [obs.result for obs in observation.observations] + raise TypeError(f"Unexpected observation type: {type(observation).__name__}") + + function.__name__ = action_type.name.replace(".", "__") + + docstring_parts = [action_type.description or action_type.name.replace("_", " ")] + + arguments_type = action_type.arguments + if not isinstance(arguments_type, type) or not issubclass(arguments_type, BaseModel): + raise TypeError(f"Action arguments must be a Pydantic BaseModel, got {arguments_type!r}") + + params = [] + annotations: dict[str, Any] = {} + + fields = arguments_type.model_fields + if fields: + docstring_parts.extend(["", "Args:"]) + for field_name, field_info in fields.items(): + anno = field_info.annotation or Any + + default = field_info.default + required = default is PydanticUndefined + if default is None: + required = False + + param = inspect.Parameter( + name=field_name, + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=anno, + default=(inspect._empty if required else default), + ) + params.append(param) + annotations[field_name] = anno + + # Description and Google-style formatting + desc = field_info.description or field_name.replace("_", " ") + type_name = getattr(anno, "__name__", None) or str(anno).replace("typing.", "") + docstring_parts.append(f" {field_name} ({type_name}): {desc}") + + function.__signature__ = inspect.Signature(parameters=params) + function.__annotations__ = annotations + else: + function.__signature__ = inspect.Signature(parameters=[]) + function.__annotations__ = {} + + function.__annotations__["return"] = Any + function.__doc__ = "\n".join(docstring_parts) + + return function + + +def bind_arguments(cls: type[BaseModel], args: list[Any], kwargs: dict[str, Any]) -> dict[str, Any]: + """Bind positional args to Pydantic model fields by declaration order.""" + field_names = list(cls.model_fields.keys()) + if len(args) > len(field_names): + raise TypeError( + f"Too many positional arguments for {cls.__name__} " + f"(expected at most {len(field_names)}, got {len(args)})" + ) + + positional_names = field_names[: len(args)] + _duplicates = set(positional_names) & set(kwargs.keys()) + if _duplicates: + dup_list = ", ".join(sorted(_duplicates)) + raise TypeError(f"Multiple values for argument(s): {dup_list}") + + bound = dict(zip(positional_names, args)) + bound.update(kwargs) + return bound diff --git a/labs/AgentStream/exgentic/src/exgentic/adapters/agents/__init__.py b/labs/AgentStream/exgentic/src/exgentic/adapters/agents/__init__.py new file mode 100644 index 00000000..ed18cc54 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/adapters/agents/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +__all__ = [] diff --git a/labs/AgentStream/exgentic/src/exgentic/adapters/agents/code_agent.py b/labs/AgentStream/exgentic/src/exgentic/adapters/agents/code_agent.py new file mode 100644 index 00000000..bf638121 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/adapters/agents/code_agent.py @@ -0,0 +1,52 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from abc import abstractmethod +from typing import Callable, List, Optional + +from ...core import Observation +from ..actions.functions import action_type_to_function +from .coordinator import AgentCoordinator, CoordinatedAgent + + +class CodeAgentInstance(CoordinatedAgent, AgentCoordinator): + """Base class for code-based agents that inherits both roles.""" + + def __init__(self, session_id: str): + self.initial_observation: Optional[Observation] = None + # Initialize AgentCoordinator with self as the internal agent + AgentCoordinator.__init__(self, session_id, self) + + def run(self, adapter) -> None: + """Implementation of CoordinatedAgent.run that converts actions to functions. + + When the code agent calls one of the functions, what actually happens, is that the + AgentCoordinator.execute() method is called with the action. This places the action in an + action queue, creates a future for the result, and waits for it. + + The AgentCordinator, which is running in a different thread, waits for an action in the + queue, fetches it and passes it to the benchmark environment. When the AgentCordinator receives + the coressponding observation, it places it in the result future. + + This unblocks the CoordinateAgent, and cause the function to return the value. + The code agent then continues its run. + + """ + functions = [] + + for action_type in self.actions: + function = action_type_to_function(action_type, self.execute) + functions.append(function) + + # Block until the environment delivers the initial observation via adapter.react() + self.initial_observation = adapter.get_observation() + + try: + self.run_code_agent(functions) + finally: + self.execute(None) # Mark execution as done, by returning no action to the benchmark. + + @abstractmethod + def run_code_agent(self, functions: List[Callable]) -> None: + """Subclasses implement their code agent logic here.""" + pass diff --git a/labs/AgentStream/exgentic/src/exgentic/adapters/agents/coordinator.py b/labs/AgentStream/exgentic/src/exgentic/adapters/agents/coordinator.py new file mode 100644 index 00000000..ebd58c9d --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/adapters/agents/coordinator.py @@ -0,0 +1,321 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import contextvars +import threading +import time +import traceback +from abc import ABC, abstractmethod +from typing import List, Optional + +from ...core.agent_instance import AgentInstance +from ...core.types import ( + Action, + MultiObservation, + Observation, + ParallelAction, + SingleAction, +) + + +class CoordinatedAgent(ABC): + """Internal agent that runs inside an AgentCoordinator. + + The agent: + - receives observations via get_observation() + - sends actions via execute() + - signals termination by execute(None) + """ + + @abstractmethod + def run(self, adapter) -> None: + pass + + +class AgentCoordinator(AgentInstance): + """Coordinates turn-based communication between threads. + + - an environment thread (react) + - an internal agent thread (run / execute) + """ + + def __init__( + self, + session_id, + internal_agent: CoordinatedAgent, + accumulate_window_seconds: float | None = None, + ): + super().__init__(session_id) + self.internal_agent = internal_agent + self._accumulate_window_seconds = accumulate_window_seconds + + self._condition = threading.Condition() + self._thread: Optional[threading.Thread] = None + self._started = False + self._closed = False + self._agent_error: Optional[BaseException] = None + self._agent_traceback: str | None = None + + self._turn = 0 + self._current_observation: Observation | None = None + self._agent_seen_turn = -1 + + self._pending_actions: List[Action | None] = [] + self._last_actions: List[SingleAction] = [] + + def start(self, task, context, actions) -> None: + """Receive work payload and start the internal agent thread (once).""" + super().start(task, context, actions) + with self._condition: + if self._started: + raise RuntimeError("AgentCoordinator already started") + self._started = True + ctx = contextvars.copy_context() + self._thread = threading.Thread( + target=ctx.run, + args=(self._run_internal_agent,), + name=f"AgentCoordinator[{self.session_id}]", + daemon=False, + ) + self._thread.start() + + def _run_internal_agent(self) -> None: + """Entry point for the internal agent thread.""" + try: + self.internal_agent.run(self) + except BaseException as exc: + with self._condition: + self._agent_error = exc + self._agent_traceback = traceback.format_exc() + self._closed = True + self._turn += 1 + self._current_observation = None + self._pending_actions.clear() + self._condition.notify_all() + self.logger.exception("Internal agent crashed") + finally: + self.close() + + def _raise_if_agent_failed(self) -> None: + if self._agent_error is not None: + if isinstance(self._agent_error, Exception): + tb = self._agent_traceback or "" + raise RuntimeError(f"{self._agent_error}\n\n{tb}") from self._agent_error + raise RuntimeError("Internal agent failed") from self._agent_error + + def _flush_actions(self) -> Action | None: + """Combine pending actions into a single Action or ParallelAction.""" + if not self._pending_actions: + return None + actions = self._pending_actions + self._pending_actions = [] + + if any(a is None for a in actions): + self._closed = True + return None + + return actions[0] if len(actions) == 1 else ParallelAction(actions=actions) + + def _remember_actions(self, action: Action | None) -> None: + if isinstance(action, Action): + actions = list(action.to_action_list()) + if all(isinstance(act, SingleAction) for act in actions): + self._last_actions = actions + return + else: + self._last_actions = [] + return + self._last_actions = [] + + def _rewire_observation(self, observation: Observation | None) -> Observation | None: + if observation is None or not self._last_actions: + return observation + if not isinstance(observation, Observation): + return observation + + obs_list = observation.to_observation_list() + if not obs_list: + return observation + + if len(obs_list) == 1 and len(self._last_actions) > 1: + obs = obs_list[0] + if not obs.invoking_actions: + obs.invoking_actions = list(self._last_actions) + return observation + + used_ids = {act.id for obs in obs_list for act in obs.invoking_actions if isinstance(act, SingleAction)} + remaining = [act for act in self._last_actions if act.id not in used_ids] + for obs in obs_list: + if obs.invoking_actions: + continue + if not remaining: + break + obs.invoking_actions = [remaining.pop(0)] + + if remaining: + self.logger.warning( + "Unassigned actions after rewiring observations (actions=%s, observations=%s)", + len(self._last_actions), + len(obs_list), + ) + return observation + + def _select_observation_for_action( + self, action: Action | None, observation: Observation | None + ) -> Observation | None: + if observation is None: + return observation + if not isinstance(observation, Observation): + return observation + if not isinstance(action, SingleAction): + return observation + + obs_list = observation.to_observation_list() + if not obs_list: + return observation + + matched = [ + obs + for obs in obs_list + if any(isinstance(inv, SingleAction) and inv.id == action.id for inv in obs.invoking_actions) + ] + if matched: + if len(matched) == 1: + return matched[0] + return MultiObservation(observations=matched) + + self.logger.warning( + "No matching observation for action id=%s (observations=%s)", + action.id, + len(obs_list), + ) + return observation + + def _publish_observation(self, observation: Observation | None) -> None: + self.logger.info("Publishing observation (turn=%s): %s", self._turn + 1, observation) + observation = self._rewire_observation(observation) + self._last_actions = [] + self._current_observation = observation + self._turn += 1 + self._condition.notify_all() + + def _wait_for_pending_actions(self) -> bool: + while not self._closed and not self._pending_actions: + self._condition.wait() + self._raise_if_agent_failed() + return not self._closed + + def _accumulate_pending_actions(self) -> None: + if not self._accumulate_window_seconds: + return + if any(a is None for a in self._pending_actions): + return + deadline = time.monotonic() + self._accumulate_window_seconds + while not self._closed: + remaining = deadline - time.monotonic() + if remaining <= 0: + return + if any(a is None for a in self._pending_actions): + return + self._condition.wait(timeout=remaining) + + def get_observation(self) -> Observation | None: + """Block until a new observation is available or the coordinator closes.""" + with self._condition: + self._raise_if_agent_failed() + if self._closed: + return None + + # Wait until a new observation is published by the environment thread. + # We also block if _current_observation is still None (initial call). + while not self._closed and (self._turn <= self._agent_seen_turn or self._current_observation is None): + self._condition.wait() + + self._raise_if_agent_failed() + if self._closed: + return None + + self._agent_seen_turn = self._turn + self.logger.info( + "Delivered observation (turn=%s): %s", + self._turn, + self._current_observation, + ) + return self._current_observation + + def execute(self, action: Action | None) -> Observation | None: + """Publish an action for the current turn. + + If action is None, signals agent termination. + """ + with self._condition: + self._raise_if_agent_failed() + if self._closed: + if action is not None: + raise RuntimeError("execute() called after close()") + return None + + self.logger.info("Received action (turn=%s): %s", self._turn, action) + self._pending_actions.append(action) + self._condition.notify_all() + + if action is None: + self._closed = True + return None + + my_turn = self._turn + while not self._closed and self._turn == my_turn: + self._condition.wait() + + self._raise_if_agent_failed() + if self._closed: + return None + + self._agent_seen_turn = self._turn + self.logger.info( + "Delivered observation after action (turn=%s): %s", + self._turn, + self._current_observation, + ) + return self._select_observation_for_action(action, self._current_observation) + + def react(self, observation: Observation | None) -> Action | None: + """Publish an observation and wait for the agent's action. + + If observation is None, signals environment termination. + """ + with self._condition: + self._raise_if_agent_failed() + if self._closed: + if observation is not None: + raise RuntimeError("react() called after close()") + return None + + self._publish_observation(observation) + + if observation is None: + self._closed = True + return None + + if not self._wait_for_pending_actions(): + return None + + self._accumulate_pending_actions() + + action = self._flush_actions() + self._remember_actions(action) + return action + + def close(self) -> None: + """Close the coordinator and wait for the agent thread to exit.""" + with self._condition: + if not self._closed: + self._closed = True + self._turn += 1 + self._current_observation = None + self._pending_actions.clear() + self._condition.notify_all() + t = self._thread + + if t and t.is_alive() and t is not threading.current_thread(): + t.join() diff --git a/labs/AgentStream/exgentic/src/exgentic/adapters/agents/mcp_agent.py b/labs/AgentStream/exgentic/src/exgentic/adapters/agents/mcp_agent.py new file mode 100644 index 00000000..424b20f7 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/adapters/agents/mcp_agent.py @@ -0,0 +1,104 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import abc +from abc import abstractmethod +from typing import Any, Callable, List, Optional + +from ..actions.functions import action_type_to_function +from .code_agent import CodeAgentInstance +from .mcp_server import MCPServer + + +class MCPAgentInstance(CodeAgentInstance, abc.ABC): + """Sync-first base class. + + - run_code_agent(): sync + - run_mcp_agent(): ABSTRACT SYNC (subclass decides implementation strategy) + """ + + def __init__(self, session_id: str): + self._mcp_server: Optional[MCPServer] = None + super().__init__(session_id) + + def run(self, adapter) -> None: + functions: List[Callable[..., Any]] = [] + for action_type in self.actions: + if action_type.is_finish: + function = action_type_to_function(action_type, self._submit_finish_action) + else: + function = action_type_to_function(action_type, self.execute) + functions.append(function) + + self.initial_observation = adapter.get_observation() + + try: + self.run_code_agent(functions) + finally: + self.execute(None) + + def run_code_agent(self, functions: List[Callable[..., Any]]) -> Any: + """Fully synchronous entrypoint.""" + self.logger.info("Starting MCP server for agent tools") + server = MCPServer( + tools=functions, + log_dir=self.paths.agent_dir, + logger=self.logger, + stringify_empty_output=self._stringify_empty_output(), + ) + self._mcp_server = server + self.mcp = server.mcp + started = False + try: + with server: + started = True + self.logger.info( + "MCP server ready at http://%s:%s/mcp", + server.connect_host, + server.port, + ) + return self.run_mcp_agent(server.connect_host, server.port) + finally: + if started: + self.logger.info("MCP server stopped") + + def _stringify_empty_output(self) -> bool: + return False + + def _submit_finish_action(self, action) -> None: + with self._condition: + self._raise_if_agent_failed() + if self._closed: + if action is not None: + raise RuntimeError("execute() called after close()") + return + + self.logger.info("Received finish action (turn=%s): %s", self._turn, action) + self._pending_actions.append(action) + self._condition.notify_all() + return + + def close_mcp_agent(self) -> None: + self.close() + + def close(self) -> None: + super().close() + if self._mcp_server is not None: + self.logger.info("Stopping MCP server") + self._mcp_server.stop(raise_on_timeout=False) + + @abstractmethod + def run_mcp_agent(self, mcp_host: str, mcp_port: int) -> Any: + """ABSTRACT SYNC. + + Subclass may implement: + - purely sync logic, OR + - a sync wrapper over an async core (via run_sync, etc.) + """ + ... + + +class MCPAgent(MCPAgentInstance, abc.ABC): + """Backwards-compatible alias for MCPAgentInstance.""" + + pass diff --git a/labs/AgentStream/exgentic/src/exgentic/adapters/agents/mcp_server.py b/labs/AgentStream/exgentic/src/exgentic/adapters/agents/mcp_server.py new file mode 100644 index 00000000..1a97ebb6 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/adapters/agents/mcp_server.py @@ -0,0 +1,351 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import functools +import inspect +import logging +import socket +import threading +import time +from typing import Any, Callable, Iterable, Optional + +import uvicorn +from mcp.client.session import ClientSession +from mcp.client.streamable_http import streamable_http_client +from mcp.server.fastmcp.server import FastMCP +from pydantic import BaseModel + +from ...observers.logging import ( + configure_library_file_logging, + configure_uvicorn_file_logging, + get_logger, +) +from ...utils.sync import run_sync + + +class MCPServerConfig(BaseModel): + http_timeout_seconds: float | None = None # None means no timeout + sse_read_timeout_seconds: float | None = None # None means no timeout + http_connect_timeout_seconds: float | None = None # None means no timeout + headers: dict[str, str] | None = None + terminate_on_close: bool = True + + +class MCPServer: + _MAX_SAFE_SCHEMA_INT = 2_147_483_647 + + def __init__( + self, + mcp: FastMCP | None = None, + *, + host: str | None = None, + port: int | None = None, + tools: Iterable[Callable[..., Any]] | None = None, + log_dir, + logger: logging.Logger, + stringify_empty_output: bool = False, + ) -> None: + self._mcp = mcp or self._build_fastmcp() + self._host = host or "0.0.0.0" + self._port = port + self._log_dir = log_dir + self._logger = logger + self._stringify_empty_output = stringify_empty_output + self._mcp_log_dir = self._log_dir / "mcp" + self._mcp_log_dir.mkdir(parents=True, exist_ok=True) + self._server_logger = get_logger( + f"MCPServer_{id(self)}", + str(self._mcp_log_dir / "server.log"), + ) + ts = self._mcp.settings.transport_security + ts.allowed_hosts = [ + *ts.allowed_hosts, + "host.containers.internal:*", + "host.docker.internal:*", + ] + ts.allowed_origins = [ + *ts.allowed_origins, + "http://host.containers.internal:*", + "http://host.docker.internal:*", + ] + + tool_names: list[str] = [] + if tools: + for fn in tools: + tool_fn = self._wrap_tool(fn) if self._stringify_empty_output else fn + tool = self._mcp._tool_manager.add_tool(tool_fn) + tool.parameters = self._clamp_schema_ints(tool.parameters) + tool_names.append(fn.__name__) + self._log_tool_summary(tool_names) + + if self._logger is not None: + self._logger.info("MCP logs at %s", self._mcp_log_dir) + + self._server: Optional[uvicorn.Server] = None + self._thread: Optional[threading.Thread] = None + self._sock: Optional[socket.socket] = None + self._started = threading.Event() + + @classmethod + def _clamp_schema_ints(cls, obj: Any) -> Any: + if isinstance(obj, dict): + return {k: cls._clamp_schema_ints(v) for k, v in obj.items()} + if isinstance(obj, list): + return [cls._clamp_schema_ints(v) for v in obj] + if isinstance(obj, int) and not isinstance(obj, bool): + if obj > cls._MAX_SAFE_SCHEMA_INT: + return cls._MAX_SAFE_SCHEMA_INT + if isinstance(obj, float): + if obj > cls._MAX_SAFE_SCHEMA_INT: + return float(cls._MAX_SAFE_SCHEMA_INT) + return obj + + @staticmethod + def stringify_empty_output(result: Any) -> Any: + if result is None: + return "null" + if result == []: + return "[]" + return result + + @classmethod + def _wrap_tool(cls, fn: Callable[..., Any]) -> Callable[..., Any]: + signature = inspect.signature(fn) + + if inspect.iscoroutinefunction(fn): + + @functools.wraps(fn) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + result = await fn(*args, **kwargs) + return cls.stringify_empty_output(result) + + else: + + @functools.wraps(fn) + def wrapper(*args: Any, **kwargs: Any) -> Any: + result = fn(*args, **kwargs) + return cls.stringify_empty_output(result) + + wrapper.__signature__ = signature # type: ignore[attr-defined] + return wrapper + + def _build_fastmcp(self) -> FastMCP: + root = logging.getLogger() + null_handler: logging.Handler | None = None + if not root.handlers: + null_handler = logging.NullHandler() + root.addHandler(null_handler) + try: + return FastMCP() + finally: + if null_handler is not None: + try: + root.removeHandler(null_handler) + except Exception: + pass + + @property + def server(self) -> Optional[uvicorn.Server]: + return self._server + + @property + def thread(self) -> Optional[threading.Thread]: + return self._thread + + @property + def mcp(self) -> FastMCP: + return self._mcp + + @property + def host(self) -> str: + return self._host + + @property + def connect_host(self) -> str: + # Use 127.0.0.1 for connecting, even if server binds to 0.0.0.0. + return "127.0.0.1" if self._host == "0.0.0.0" else self._host + + @property + def port(self) -> int: + if self._port is None: + raise RuntimeError("MCP server port not assigned yet.") + return self._port + + def start( + self, + timeout: float = 60.0, + *, + tcp_timeout: float = 60.0, + ping_timeout: float = 60.0, + ) -> None: + if self._thread and self._thread.is_alive(): + return + + self._ensure_socket() + + self._started.clear() + t = threading.Thread( + target=self._thread_entry, + name=f"mcp-native:{self._port}", + daemon=True, + ) + self._thread = t + t.start() + + if not self._started.wait(timeout=timeout): + raise RuntimeError("MCP uvicorn thread did not signal startup within timeout.") + + try: + started_at = time.time() + # Use 127.0.0.1 for connecting, even if server binds to 0.0.0.0 + connect_host = "127.0.0.1" if self._host == "0.0.0.0" else self._host + wait_for_tcp(connect_host, self.port, timeout=tcp_timeout) + tcp_elapsed = time.time() - started_at + ping_started = time.time() + run_sync( + wait_for_mcp_ping_async(connect_host, self.port, timeout=ping_timeout), + timeout=ping_timeout + 5.0, + ) + ping_elapsed = time.time() - ping_started + self._server_logger.info( + "MCP readiness OK (tcp=%.2fs, ping=%.2fs)", + tcp_elapsed, + ping_elapsed, + ) + except BaseException as exc: + self.stop(error=exc, raise_on_timeout=False) + raise + + def __enter__(self) -> "MCPServer": + self.start() + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.stop(error=exc, raise_on_timeout=True) + + def stop( + self, + timeout: float = 60.0, + *, + error: BaseException | None = None, + raise_on_timeout: bool = True, + ) -> None: + if self._server is not None: + self._server.should_exit = True + + t = self._thread + if t and t.is_alive(): + t.join(timeout=timeout) + if t.is_alive(): + message = "MCP uvicorn server thread did not exit cleanly" + if raise_on_timeout and error is None: + raise RuntimeError(message) + self._server_logger.warning(message) + if self._sock is not None: + try: + self._sock.close() + finally: + self._sock = None + + def _thread_entry(self) -> None: + thread_id = threading.get_ident() + cleanup_uvicorn = configure_uvicorn_file_logging( + self._mcp_log_dir / "uvicorn.log", + thread_id=thread_id, + ) + cleanup_mcp = configure_library_file_logging( + self._mcp_log_dir / "server.log", + logger_names=["mcp", "mcp.server", "mcp.client"], + thread_id=thread_id, + ) + + server = self._build_server() + + self._server_logger.info( + "Starting MCP server on %s:%s (path=%s)", + self._host, + self.port, + self._mcp.settings.streamable_http_path, + ) + self._started.set() + + try: + sock = self._sock + if sock is not None: + self._server_logger.info("Using pre-bound socket on %s:%s", self._host, self.port) + server.run(sockets=[sock]) + else: + server.run() + finally: + self._server_logger.info("MCP server stopped") + cleanup_mcp() + cleanup_uvicorn() + + def _ensure_socket(self) -> None: + if self._sock is not None: + return + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind((self._host, self._port or 0)) + sock.listen(1) + except Exception: + sock.close() + raise + self._sock = sock + self._port = int(sock.getsockname()[1]) + + def _build_server(self) -> uvicorn.Server: + app = self._mcp.streamable_http_app() + config = uvicorn.Config(app, host=self._host, port=self.port, log_config=None) + server = uvicorn.Server(config) + self._server = server + return server + + def _log_tool_summary(self, tool_names: list[str]) -> None: + count = len(tool_names) + if count == 0: + self._server_logger.info("Registered 0 MCP tools") + return + preview = ", ".join(tool_names[:6]) + if count > 6: + preview = f"{preview}, +{count - 6} more" + self._server_logger.info("Registered %s MCP tool(s): %s", count, preview) + + +def wait_for_tcp(host: str, port: int, timeout: float = 60.0) -> None: + deadline = time.time() + timeout + last_err: Optional[BaseException] = None + while time.time() < deadline: + try: + with socket.create_connection((host, port), timeout=0.5): + return + except Exception as e: + last_err = e + time.sleep(0.1) + raise TimeoutError(f"Server at {host}:{port} did not open TCP port within {timeout}s. " f"Last error: {last_err!r}") + + +async def wait_for_mcp_ping_async(host: str, port: int, timeout: float = 60.0) -> None: + deadline = time.time() + timeout + last_err: Optional[BaseException] = None + url = f"http://{host}:{port}/mcp" + + import asyncio # local import to keep module sync-first + + while time.time() < deadline: + try: + async with streamable_http_client(url) as ( + read_stream, + write_stream, + _, + ): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + await session.send_ping() + return + except Exception as e: + last_err = e + await asyncio.sleep(0.1) + + raise TimeoutError(f"MCP ping at {host}:{port} failed within {timeout}s. " f"Last error: {last_err!r}") diff --git a/labs/AgentStream/exgentic/src/exgentic/adapters/executors/__init__.py b/labs/AgentStream/exgentic/src/exgentic/adapters/executors/__init__.py new file mode 100644 index 00000000..ed18cc54 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/adapters/executors/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +__all__ = [] diff --git a/labs/AgentStream/exgentic/src/exgentic/adapters/executors/proxy.py b/labs/AgentStream/exgentic/src/exgentic/adapters/executors/proxy.py new file mode 100644 index 00000000..e21e291b --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/adapters/executors/proxy.py @@ -0,0 +1,199 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from abc import ABC, abstractmethod +from argparse import Action +from queue import Queue +from threading import Event, Lock, Semaphore +from typing import Any, Generic, Optional, TypeVar + +from ...core.session import Session +from ...core.types import Observation + +DONE = object() + + +class BaseProxySession(Session, ABC): + """Generic queue-based proxy session. + + Implements a simple rendezvous between an external driver (agent/framework) + and a foreign environment adapter via two blocking queues. + """ + + def __init__(self): + super().__init__() + self.completed = False + self.step_count = 0 + self._to_agent: Queue = Queue() + self._from_agent: Queue = Queue() + self._last_observation: Optional[Any] = None + + def step(self, action: Action) -> Optional[Observation]: + self.step_count += 1 + self._from_agent.put(action) + next_obs = self._to_agent.get() + return next_obs + + def start(self) -> Optional[Observation]: + result = self._to_agent.get() + return result + + def done(self) -> bool: + result = self.completed + return result + + def score(self) -> dict: + return { + "success": self.completed, + "steps": self.step_count, + "score": max(0.0, 1.0 - (self.step_count - 1) * 0.1), + } + + def close(self): + self.completed = True + try: + self._from_agent.put_nowait(DONE) + except Exception: + pass + try: + self._to_agent.put_nowait(DONE) + except Exception: + pass + + # --- Hooks for subclasses --- + def put_observation(self, obs: Any) -> None: + self._last_observation = obs + self._to_agent.put(obs) + + def wait_for_action(self) -> Optional[Any]: + item = self._from_agent.get() + if item is DONE: + self.completed = True + return None + return item + + +_PAIRING_SEMAPHORE: Semaphore = Semaphore(1) # Only 1 session can be staged at a time +_PAIRING_LOCK: Lock = Lock() +_CURRENT_SESSION: Optional[PairableProxySession] = None + + +class PairableProxySession(BaseProxySession): + """Proxy session that can be staged and paired with a proxy agent automatically.""" + + def __init__(self): + super().__init__() + self._paired_event: Event = Event() + + # Pairing API + def stage_for_pairing(self) -> None: + _PAIRING_SEMAPHORE.acquire() # Blocks until slot available + with _PAIRING_LOCK: + global _CURRENT_SESSION + _CURRENT_SESSION = self + + def _mark_paired(self) -> None: + self._paired_event.set() + + def waiting_for_pairing(self): + return _CURRENT_SESSION == self and not self._paired_event.is_set() + + def unstage_for_pairing(self): + if self.waiting_for_pairing(): + with _PAIRING_LOCK: + global _CURRENT_SESSION + assert _CURRENT_SESSION == self + _CURRENT_SESSION = None + _PAIRING_SEMAPHORE.release() + + def pair_to_agent(self, timeout: Optional[float] = 10.0) -> None: + ok = self._paired_event.wait(timeout=timeout) + if not ok: + raise RuntimeError("Timed out waiting for proxy agent to pair with session") + + # Gate helpers + @classmethod + def block_pairing(cls) -> None: + pass # No longer needed with lock-based approach + + @classmethod + def allow_pairing(cls) -> None: + pass # No longer needed with lock-based approach + + @classmethod + def pairing_allowed(cls) -> bool: + return True # Always allowed with lock-based approach + + +SessionT = TypeVar("SessionT", bound=BaseProxySession) + + +class BaseProxyAgent(ABC, Generic[SessionT]): + """Base mixin providing generic step handling between a proxy session and an external environment. + + Uses core terms (session, observation, action). + + Adapters should call `handle_observation(observation, state)` from their + environment-specific entrypoint. + """ + + def _ensure_session(self, state: Optional[SessionT], observation: Any) -> SessionT: + if state is None: + return self.create_session(observation) + self.update_session_observation(state, observation) + return state + + def handle_observation(self, observation: Any, state: Optional[SessionT]): + """Generic step handler: ensure session, wait for action, translate response. + + - observation: an environment-specific observation object + - state: the proxy session instance (or None for a new session) + Returns (environment-specific response, new_state). + """ + session = self._ensure_session(state, observation) + action = session.wait_for_action() + response_obj, new_state = self.action_to_response(action, observation, session) + return response_obj, new_state + + # --- Subclass hooks --- + @abstractmethod + def create_session(self, first_observation: Any) -> SessionT: + pass + + @abstractmethod + def update_session_observation(self, session: SessionT, observation: Any) -> None: + pass + + @abstractmethod + def action_to_response(self, action: Any, observation: Any, session: SessionT): + pass + + +class PairableProxyAgent(BaseProxyAgent[SessionT]): + """Proxy agent that adopts the currently staged PairableProxySession.""" + + def adopt_staged_session(self) -> SessionT: + with _PAIRING_LOCK: + global _CURRENT_SESSION + sess = _CURRENT_SESSION + if sess is None: + raise RuntimeError("No staged session available for pairing") + _CURRENT_SESSION = None + sess._mark_paired() # type: ignore[attr-defined] + _PAIRING_SEMAPHORE.release() # Release slot for next session + return sess # type: ignore[return-value] + + # Gate helpers + @classmethod + def block_pairing(cls) -> None: + PairableProxySession.block_pairing() + + @classmethod + def allow_pairing(cls) -> None: + PairableProxySession.allow_pairing() + + @classmethod + def pairing_allowed(cls) -> bool: + return PairableProxySession.pairing_allowed() diff --git a/labs/AgentStream/exgentic/src/exgentic/adapters/runners/__init__.py b/labs/AgentStream/exgentic/src/exgentic/adapters/runners/__init__.py new file mode 100644 index 00000000..84d0360b --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/adapters/runners/__init__.py @@ -0,0 +1,125 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Runner & Transport abstractions for running objects in different isolation levels. + +Runners wrap any object and control where it executes: + +- ``direct`` — same thread, no isolation +- ``thread`` — separate thread, queue-based communication +- ``process`` — separate process, pipe-based communication with cloudpickle +- ``service`` — HTTP service in a background thread +- ``docker`` — HTTP service inside a Docker container +- ``venv`` — HTTP service in an isolated uv virtual environment + +Usage:: + + calc = with_runner(Calculator, runner="thread", value=10) +""" + +from __future__ import annotations + +from typing import Any, Literal + +from .direct import DirectTransport +from .transport import ObjectHost, ObjectProxy, Transport + +RunnerName = Literal["direct", "thread", "process", "service", "docker", "venv"] + + +def _resolve_cls(cls: type | str) -> type: + """Resolve a ``"module:qualname"`` string to the actual class.""" + if isinstance(cls, type): + return cls + module_path, qualname = cls.rsplit(":", 1) + import importlib + + mod = importlib.import_module(module_path) + obj = mod + for attr in qualname.split("."): + obj = getattr(obj, attr) + return obj # type: ignore[return-value] + + +def with_runner(cls: type | str, *args: Any, runner: RunnerName = "direct", **kwargs: Any) -> Any: + """Create an instance of *cls* running in the specified isolation level. + + *cls* may be a class or a ``"module:qualname"`` string. String + references are resolved lazily — for ``venv`` and ``docker`` runners + the string is forwarded directly so heavy imports never happen on the + host. + + Returns an ``ObjectProxy`` that transparently forwards all + attribute access and method calls to the real object. + """ + if runner == "direct": + cls = _resolve_cls(cls) + return ObjectProxy(DirectTransport(cls(*args, **kwargs))) + + if runner == "thread": + from .thread import ThreadTransport + + cls = _resolve_cls(cls) + t = ThreadTransport(cls, *args, **kwargs) + t.start() + return ObjectProxy(t) + + if runner == "process": + from .process import PipeTransport + + cls = _resolve_cls(cls) + t = PipeTransport(cls, *args, **kwargs) + t.start() + return ObjectProxy(t) + + if runner == "service": + from .service import ServiceRunner + + cls = _resolve_cls(cls) + return ServiceRunner(cls, *args, **kwargs).start() + + if runner == "docker": + from .docker import DockerRunner + + docker_kw = {} + for key in ( + "env_name", + "module_path", + "image", + "dockerfile", + "port", + "docker_args", + "dependencies", + "docker_socket", + "volumes", + ): + if key in kwargs: + docker_kw[key] = kwargs.pop(key) + return DockerRunner(cls, *args, **docker_kw, **kwargs).start() + + if runner == "venv": + from .venv import VenvRunner + + venv_kw = {} + for key in ( + "env_name", + "module_path", + "port", + "dependencies", + "health_timeout", + ): + if key in kwargs: + venv_kw[key] = kwargs.pop(key) + return VenvRunner(cls, *args, **venv_kw, **kwargs).start() + + raise ValueError(f"Unknown runner: {runner!r}") + + +__all__ = [ + "DirectTransport", + "ObjectHost", + "ObjectProxy", + "RunnerName", + "Transport", + "with_runner", +] diff --git a/labs/AgentStream/exgentic/src/exgentic/adapters/runners/_utils.py b/labs/AgentStream/exgentic/src/exgentic/adapters/runners/_utils.py new file mode 100644 index 00000000..07dac32f --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/adapters/runners/_utils.py @@ -0,0 +1,138 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Shared utilities for runner implementations.""" + +from __future__ import annotations + +import base64 +import json +import socket +from pathlib import Path +from typing import Any + + +def find_project_root() -> Path: + """Return the project root directory. + + Walks up from the exgentic package looking for a ``pyproject.toml``. + When none is found (e.g. ``uv tool install exgentic``), falls back + to ``~/.exgentic/`` so that benchmark venvs and caches still have a + stable home directory. + """ + for parent in Path(__file__).resolve().parents: + if (parent / "pyproject.toml").exists(): + return parent + fallback = Path.home() / ".exgentic" + fallback.mkdir(parents=True, exist_ok=True) + return fallback + + +def find_free_port() -> int: + """Return an unused TCP port on localhost.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("", 0)) + return s.getsockname()[1] + + +def serialize_kwargs(kwargs: dict[str, Any]) -> tuple[str, str]: + """Serialize kwargs for the ``exgentic serve`` CLI. + + Returns ``(flag, value)`` — either ``("--kwargs", json_str)`` + or ``("--kwargs-b64", pickled_b64)`` for non-JSON-serializable values. + """ + try: + return "--kwargs", json.dumps(kwargs) + except TypeError: + import cloudpickle as cp + + return "--kwargs-b64", base64.b64encode(cp.dumps(kwargs)).decode("ascii") + + +_SYSTEM_ENV_BLOCKLIST = frozenset( + { + "PATH", + "HOME", + "USER", + "SHELL", + "HOSTNAME", + "LANG", + "TERM", + "PWD", + "OLDPWD", + "SHLVL", + "_", + "TMPDIR", + "VIRTUAL_ENV", + "CONDA_DEFAULT_ENV", + "CONDA_PREFIX", + } +) +_PREFIX_BLOCKLIST = ("VSCODE_", "UV_", "PIP_") + + +def prepare_subprocess_env() -> dict[str, str]: + """Build a filtered env dict for subprocess runners (venv, docker). + + Forwards API tokens and user config while excluding system-level + vars, IDE noise, and Python-path-manager prefixes that could + conflict with the isolated environment. + """ + import os + + root = find_project_root() + project_root = str(root) if (root / "pyproject.toml").exists() else "" + + env: dict[str, str] = { + k: v + for k, v in os.environ.items() + if k not in _SYSTEM_ENV_BLOCKLIST + and not any(k.startswith(p) for p in _PREFIX_BLOCKLIST) + and not v.startswith(project_root + "/src/") + } + return env + + +def inject_exgentic_env(env: dict[str, str]) -> None: + """Add exgentic context vars and resolved settings paths into *env*. + + Mutates *env* in-place. + """ + from ...core.context import context_env + from ...environment.instance import get_manager + from ...utils.settings import get_settings + + for k, v in context_env().items(): + env[k] = v + for key in ("EXGENTIC_CTX_OUTPUT_DIR", "EXGENTIC_CTX_CACHE_DIR"): + if key in env: + env[key] = str(Path(env[key]).resolve()) + + settings = get_settings() + # Use the EnvironmentManager's base_dir (~/.exgentic/) so that + # EXGENTIC_CACHE_DIR points to the same location where benchmark + # data is actually installed. The old settings.cache_dir default + # (".exgentic") resolved to a CWD-relative path that diverged from + # the manager's absolute ~/.exgentic/ path, breaking Docker mounts. + manager = get_manager() + env.setdefault("EXGENTIC_CACHE_DIR", str(manager.base_dir)) + env.setdefault("EXGENTIC_OUTPUT_DIR", str(Path(settings.output_dir).resolve())) + + +def make_close(transport: Any, stop_fn: Any) -> Any: + """Create a close function for an ObjectProxy. + + Attempts a graceful ``close()`` on the remote object, then shuts + down the transport and calls *stop_fn* to tear down the underlying + process/container. + """ + + def _close() -> None: + try: + transport.call("close") + except Exception: + pass + transport.close() + stop_fn() + + return _close diff --git a/labs/AgentStream/exgentic/src/exgentic/adapters/runners/direct.py b/labs/AgentStream/exgentic/src/exgentic/adapters/runners/direct.py new file mode 100644 index 00000000..c00787ae --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/adapters/runners/direct.py @@ -0,0 +1,35 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""DirectTransport — no isolation, calls the object in the same thread.""" + +from __future__ import annotations + +from typing import Any + +from .transport import ObjectHost, Transport + + +class DirectTransport(Transport): + """Calls the object directly in the same thread and process. + + Useful as a baseline and as the default runner. + """ + + def __init__(self, obj: Any) -> None: + self._host = ObjectHost(obj) + + def call(self, method: str, *args: Any, **kwargs: Any) -> Any: + return self._host.handle("call", method, *args, **kwargs) + + def get(self, name: str) -> Any: + return self._host.handle("get", name) + + def set(self, name: str, value: Any) -> None: + self._host.handle("set", name, value) + + def close(self) -> None: + pass + + def __repr__(self) -> str: + return f"DirectTransport({self._host.obj!r})" diff --git a/labs/AgentStream/exgentic/src/exgentic/adapters/runners/docker.py b/labs/AgentStream/exgentic/src/exgentic/adapters/runners/docker.py new file mode 100644 index 00000000..4ac7822b --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/adapters/runners/docker.py @@ -0,0 +1,227 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""DockerRunner — runs the HTTP service inside a Docker container. + +Uses the same HTTPTransport as ServiceRunner, but the uvicorn server +runs inside a container instead of a local thread. + +The Docker image is managed by the EnvironmentManager — DockerRunner +only starts the container and wires up volumes, ports and env vars. +""" + +from __future__ import annotations + +import atexit +import shutil +import subprocess +from pathlib import Path +from typing import Any + +from ._utils import ( + find_free_port, + inject_exgentic_env, + make_close, + prepare_subprocess_env, + serialize_kwargs, +) +from .service import HTTPTransport, _wait_for_health +from .transport import ObjectProxy + + +def _docker(*args: str, check: bool = True, **kwargs: Any) -> subprocess.CompletedProcess: + docker_bin = shutil.which("docker") + if docker_bin is None: + raise RuntimeError("docker CLI not found on PATH") + return subprocess.run([docker_bin, *args], check=check, **kwargs) + + +class DockerRunner: + """Start a containerised HTTP service and return an ObjectProxy. + + Parameters + ---------- + target_cls: Class to instantiate inside the container. + env_name: Environment name for EnvironmentManager (e.g. "benchmarks/bfcl"). + module_path: Dotted module path for locating package resources. + image: Pre-built image name (skips EM lookup). + dockerfile: Path to a Dockerfile to build from. + port: Host port to bind (auto-selected if None). + docker_args: Extra arguments forwarded to ``docker run``. + dependencies: Pip packages to install in the image. + docker_socket: Mount the host Docker socket into the container. + volumes: Host-to-container volume mappings (``{host: container}``). + """ + + def __init__( + self, + target_cls: type | str, + *args: Any, + env_name: str = "", + module_path: str = "", + image: str | None = None, + dockerfile: str | None = None, + port: int | None = None, + docker_args: list[str] | None = None, + dependencies: list[str] | None = None, + docker_socket: bool = False, + volumes: dict[str, str] | None = None, + **kwargs: Any, + ) -> None: + if args: + raise ValueError( + "DockerRunner requires keyword-only constructor arguments. " + "Pass all arguments as kwargs instead of positional args." + ) + self._target_cls = target_cls + self._kwargs = kwargs + self._env_name = env_name + self._module_path = module_path + self._image = image + self._dockerfile = dockerfile + self._port = port or find_free_port() + self._docker_args = docker_args or [] + self._dependencies = dependencies or [] + self._docker_socket = docker_socket + self._volumes = volumes or {} + self._container_id: str | None = None + + # ── image handling ─────────────────────────────────────────────── + + def _ensure_image(self) -> str: + if self._image: + return self._image + + if self._dockerfile: + tag = f"exgentic-runner-custom:{hash(self._dockerfile) & 0xFFFFFFFF:08x}" + path = Path(self._dockerfile) + _docker("build", "-t", tag, "-f", str(path), str(path.parent), capture_output=True) + return tag + + if not self._env_name: + raise RuntimeError( + "DockerRunner requires 'env_name' (and usually 'module_path') " + "when no 'image' or 'dockerfile' is provided." + ) + + # Use EM's pre-built image. + from ...environment.instance import get_manager + + mgr = get_manager() + image = mgr.docker_image(self._env_name) + if image: + return image + + # Not pre-installed — install via EM now. + from ...environment import EnvType + from ...environment.helpers import get_exgentic_install_target + + project_root, packages = get_exgentic_install_target() + all_packages = (packages or []) + list(self._dependencies) + mgr.install( + self._env_name, + env_type=EnvType.DOCKER, + module_path=self._module_path, + docker_socket=self._docker_socket, + project_root=project_root, + packages=all_packages or None, + ) + image = mgr.docker_image(self._env_name) + if not image: + raise RuntimeError(f"EM install succeeded but no Docker image found for {self._env_name}") + return image + + # ── container lifecycle ────────────────────────────────────────── + + def start(self) -> ObjectProxy: + image = self._ensure_image() + + if isinstance(self._target_cls, str): + cls_ref = self._target_cls + else: + cls_ref = f"{self._target_cls.__module__}:{self._target_cls.__qualname__}" + kwargs_flag, kwargs_value = serialize_kwargs(self._kwargs) + + run_args: list[str] = ["run", "-d", "-p", f"{self._port}:8080"] + + # Forward host environment into the container (API tokens, user + # config) while excluding system-level and IDE vars. + env = prepare_subprocess_env() + inject_exgentic_env(env) + cache_dir = env.get("EXGENTIC_CACHE_DIR", "") + + for k, v in env.items(): + run_args.extend(["-e", f"{k}={v}"]) + + # Mount Docker socket for sibling container access. + if self._docker_socket: + run_args.extend(["-v", "/var/run/docker.sock:/var/run/docker.sock"]) + + # Always mount the cache dir so benchmarks that skip data downloads + # during Docker build (e.g. browsecompplus) can access host-side data, + # and benchmarks that bake data into the image (e.g. appworld) can + # also work since the volume mount overlays the image path. + Path(cache_dir).mkdir(parents=True, exist_ok=True) + run_args.extend(["-v", f"{cache_dir}:{cache_dir}"]) + + # Mount volumes. Resolve to absolute paths (Docker requires them) + # and ensure source directories exist — Docker Desktop on macOS + # cannot create mount sources in some protected paths. + for host_path, container_path in self._volumes.items(): + host_path = str(Path(host_path).resolve()) + container_path = str(Path(container_path)) if Path(container_path).is_absolute() else container_path + Path(host_path).mkdir(parents=True, exist_ok=True) + run_args.extend(["-v", f"{host_path}:{container_path}"]) + + run_args.extend(self._docker_args) + run_args.extend( + [ + image, + "exgentic", + "serve", + "--cls", + cls_ref, + kwargs_flag, + kwargs_value, + "--host", + "0.0.0.0", + "--port", + "8080", + ] + ) + + result = _docker(*run_args, capture_output=True, text=True) + self._container_id = result.stdout.strip() + atexit.register(self._stop_container) + + url = f"http://127.0.0.1:{self._port}" + try: + _wait_for_health(url, timeout=60.0) + except TimeoutError: + cid = self._container_id or "" + logs = _docker("logs", cid, check=False, capture_output=True, text=True) + status = _docker( + "inspect", "--format", "{{.State.Status}}", cid, check=False, capture_output=True, text=True + ) + self._stop_container() + raise TimeoutError( + f"Container did not become healthy within 60s.\n" + f"Status: {status.stdout.strip()}\n" + f"Logs:\n{logs.stdout}\n{logs.stderr}" + ) from None + + transport = HTTPTransport(url, timeout=600.0) + proxy = ObjectProxy(transport) + object.__setattr__(proxy, "close", make_close(transport, self._stop_container)) + return proxy + + def _stop_container(self) -> None: + if self._container_id is None: + return + cid = self._container_id + self._container_id = None + try: + _docker("stop", "-t", "2", cid, check=False, capture_output=True) + _docker("rm", "-f", cid, check=False, capture_output=True) + except Exception: + pass diff --git a/labs/AgentStream/exgentic/src/exgentic/adapters/runners/process.py b/labs/AgentStream/exgentic/src/exgentic/adapters/runners/process.py new file mode 100644 index 00000000..ed9560b4 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/adapters/runners/process.py @@ -0,0 +1,174 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""PipeTransport — runs the object in a subprocess via multiprocessing + cloudpickle.""" + +from __future__ import annotations + +import multiprocessing as mp +import weakref +from typing import Any + +import cloudpickle as cp + +from .transport import ObjectHost, Transport, deserialize_error, serialize_error + +# ── worker process ─────────────────────────────────────────────────── + + +def _worker(q_in: mp.Queue, q_out: mp.Queue) -> None: + """Subprocess entry point: create the object and serve RPC requests.""" + # Late imports — these run in the child process. + from ...core.context import init_context_from_env, set_context, try_get_context + from ...observers.logging import configure_warnings_logging + + configure_warnings_logging(replace_existing_file_handlers=False) + + try: + tag, target_cls, args, kwargs, ctx = cp.loads(q_in.get()) + assert tag == "init" + + # Restore context in child process. + if ctx is not None: + set_context(ctx) + else: + try: + init_context_from_env() + except RuntimeError: + pass # No context env vars — standalone worker + + obj = target_cls(*args, **kwargs) + + # If the object has a session_id, update context to include it. + session_id = getattr(obj, "session_id", None) + if session_id: + current_ctx = try_get_context() + if current_ctx is not None: + set_context(current_ctx.with_session(str(session_id))) + + q_out.put(cp.dumps(("ready", None))) + except Exception as exc: + q_out.put(cp.dumps(("error", serialize_error(exc)))) + return + + host = ObjectHost(obj) + while True: + try: + raw = q_in.get() + if raw is None: # shutdown sentinel + break + op, name, args, kwargs = cp.loads(raw) + try: + result = host.handle(op, name, *args, **kwargs) + q_out.put(cp.dumps(("ok", result))) + except Exception as exc: + q_out.put(cp.dumps(("error", serialize_error(exc)))) + except (EOFError, BrokenPipeError): + break + + +# ── transport ──────────────────────────────────────────────────────── + + +class PipeTransport(Transport): + """Runs the target in a child process with full memory isolation. + + Uses cloudpickle for serialization and multiprocessing queues + for communication. Propagates the exgentic Context to the child. + """ + + def __init__(self, target_cls: type, *args: Any, **kwargs: Any) -> None: + self._target_cls = target_cls + self._args = args + self._kwargs = kwargs + self._ctx = mp.get_context("spawn") + self._q_in: mp.Queue | None = None + self._q_out: mp.Queue | None = None + self._proc: mp.Process | None = None + + def start(self) -> None: + if self._proc is not None and self._proc.is_alive(): + return + + from ...core.context import context_env_scope, try_get_context + + self._q_in = self._ctx.Queue() + self._q_out = self._ctx.Queue() + self._proc = self._ctx.Process( + target=_worker, + args=(self._q_in, self._q_out), + daemon=True, + ) + # Ensure context env vars are in os.environ for the spawned process. + with context_env_scope(): + self._proc.start() + self._finalizer = weakref.finalize(self, _terminate, self._q_in, self._proc) + + # Send init payload with context. + ctx = try_get_context() + self._q_in.put(cp.dumps(("init", self._target_cls, self._args, self._kwargs, ctx))) + status, payload = self._recv() + if status == "error": + self.close() + raise deserialize_error(payload) + + # ── internal helpers ───────────────────────────────────────────── + + def _recv(self) -> tuple[str, Any]: + assert self._q_out is not None + if self._proc is not None and not self._proc.is_alive(): + raise RuntimeError(f"Worker process died (exit code: {self._proc.exitcode})") + return cp.loads(self._q_out.get()) + + def _rpc(self, op: str, name: str, *args: Any, **kwargs: Any) -> Any: + if self._proc is None or self._q_in is None or not self._proc.is_alive(): + raise RuntimeError("Worker process is not running") + self._q_in.put(cp.dumps((op, name, args, kwargs))) + status, payload = self._recv() + if status == "error": + raise deserialize_error(payload) + return payload + + # ── Transport API ──────────────────────────────────────────────── + + def call(self, method: str, *args: Any, **kwargs: Any) -> Any: + return self._rpc("call", method, *args, **kwargs) + + def get(self, name: str) -> Any: + return self._rpc("get", name) + + def set(self, name: str, value: Any) -> None: + self._rpc("set", name, value) + + def close(self) -> None: + _terminate(self._q_in, self._proc) + self._q_in = None + self._q_out = None + self._proc = None + try: + self._finalizer.detach() + except Exception: + pass + + def __repr__(self) -> str: + pid = self._proc.pid if self._proc else None + return f"PipeTransport({self._target_cls.__name__}, pid={pid})" + + +def _terminate(q_in: mp.Queue | None, proc: mp.Process | None) -> None: + """Shut down the worker process (used by both close() and the weak finalizer).""" + try: + if q_in is not None: + q_in.put(None) + except Exception: + pass + try: + if proc is not None: + proc.join(timeout=2.0) + except Exception: + pass + try: + if proc is not None and proc.is_alive(): + proc.terminate() + except Exception: + pass diff --git a/labs/AgentStream/exgentic/src/exgentic/adapters/runners/service.py b/labs/AgentStream/exgentic/src/exgentic/adapters/runners/service.py new file mode 100644 index 00000000..fa530755 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/adapters/runners/service.py @@ -0,0 +1,237 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""HTTPTransport + serve() — run any object as an HTTP service.""" + +from __future__ import annotations + +import base64 +import threading +import time +from typing import Any, Optional + +import cloudpickle as cp +import httpx +from fastapi import FastAPI +from pydantic import BaseModel as PydanticBaseModel + +from .transport import ObjectHost, ObjectProxy, Transport, deserialize_error, serialize_error + +# ── HTTP models ────────────────────────────────────────────────────── + + +class CallRequest(PydanticBaseModel): + method: str + args: str # base64(cloudpickle) + kwargs: str # base64(cloudpickle) + + +class GetRequest(PydanticBaseModel): + name: str + + +class SetRequest(PydanticBaseModel): + name: str + value: str # base64(cloudpickle) + + +class RPCResponse(PydanticBaseModel): + status: str # "ok" | "error" + result: Optional[str] = None # base64(cloudpickle) + error_type: Optional[str] = None + error_msg: Optional[str] = None + error_tb: Optional[str] = None + error_pickled: Optional[str] = None # base64(cloudpickle'd exception) + + +# ── helpers ────────────────────────────────────────────────────────── + + +def _encode(obj: Any) -> str: + return base64.b64encode(cp.dumps(obj)).decode("ascii") + + +def _decode(data: str) -> Any: + return cp.loads(base64.b64decode(data)) + + +def _error_response(exc: Exception) -> RPCResponse: + data = serialize_error(exc) + pickled_b64 = None + if data["pickled"] is not None: + pickled_b64 = base64.b64encode(data["pickled"]).decode("ascii") + return RPCResponse( + status="error", + error_type=data["type"], + error_msg=data["msg"], + error_tb=data["tb"], + error_pickled=pickled_b64, + ) + + +# ── FastAPI app ────────────────────────────────────────────────────── + + +def create_app(host: ObjectHost) -> FastAPI: + app = FastAPI() + + @app.get("/health") + def health(): + return {"status": "ok"} + + @app.post("/call") + def handle_call(req: CallRequest) -> RPCResponse: + try: + result = host.handle("call", req.method, *_decode(req.args), **_decode(req.kwargs)) + return RPCResponse(status="ok", result=_encode(result)) + except Exception as exc: + return _error_response(exc) + + @app.post("/get") + def handle_get(req: GetRequest) -> RPCResponse: + try: + return RPCResponse(status="ok", result=_encode(host.handle("get", req.name))) + except Exception as exc: + return _error_response(exc) + + @app.post("/set") + def handle_set(req: SetRequest) -> RPCResponse: + try: + host.handle("set", req.name, _decode(req.value)) + return RPCResponse(status="ok") + except Exception as exc: + return _error_response(exc) + + return app + + +# ── serve() ────────────────────────────────────────────────────────── + + +def serve(obj: Any, host: str = "0.0.0.0", port: int = 8080) -> None: + """Serve an object over HTTP (blocking).""" + import uvicorn + + uvicorn.run(create_app(ObjectHost(obj)), host=host, port=port, log_level="warning") + + +# ── HTTPTransport — client side ────────────────────────────────────── + + +class HTTPTransport(Transport): + """Talks to an HTTP server hosting an ObjectHost.""" + + def __init__(self, base_url: str, timeout: float = 30.0) -> None: + self._base_url = base_url.rstrip("/") + self._client = httpx.Client(timeout=timeout) + + def _rpc(self, endpoint: str, payload: dict) -> Any: + resp = self._client.post(f"{self._base_url}{endpoint}", json=payload) + resp.raise_for_status() + data = RPCResponse(**resp.json()) + if data.status == "error": + pickled = base64.b64decode(data.error_pickled) if data.error_pickled else None + raise deserialize_error( + { + "type": data.error_type or "RuntimeError", + "msg": data.error_msg or "", + "tb": data.error_tb or "", + "pickled": pickled, + } + ) + return _decode(data.result) if data.result is not None else None + + def call(self, method: str, *args: Any, **kwargs: Any) -> Any: + return self._rpc( + "/call", + { + "method": method, + "args": _encode(args), + "kwargs": _encode(kwargs), + }, + ) + + def get(self, name: str) -> Any: + return self._rpc("/get", {"name": name}) + + def set(self, name: str, value: Any) -> None: + self._rpc("/set", {"name": name, "value": _encode(value)}) + + def close(self) -> None: + self._client.close() + + def __repr__(self) -> str: + return f"HTTPTransport({self._base_url!r})" + + +# ── Utilities ──────────────────────────────────────────────────────── + + +def _wait_for_health(url: str, timeout: float = 15.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + if httpx.get(f"{url}/health", timeout=2.0).status_code == 200: + return + except httpx.HTTPError: + pass + time.sleep(0.1) + raise TimeoutError(f"Service at {url} did not become healthy within {timeout}s") + + +# ── ServiceRunner ──────────────────────────────────────────────────── + + +class ServiceRunner: + """Starts an HTTP service in a background thread and returns an ObjectProxy.""" + + def __init__( + self, + target_cls: type, + *args: Any, + port: int | None = None, + **kwargs: Any, + ) -> None: + self._target_cls = target_cls + self._args = args + self._kwargs = kwargs + from ._utils import find_free_port + + self._port = port or find_free_port() + self._server = None + + def start(self) -> ObjectProxy: + import uvicorn + + from ...core.context import set_context_fallback, try_get_context + + # Set process-wide fallback so context is available in uvicorn's + # request handler threads (which don't inherit ContextVar). + set_context_fallback(try_get_context()) + + obj = self._target_cls(*self._args, **self._kwargs) + app = create_app(ObjectHost(obj)) + + config = uvicorn.Config(app, host="127.0.0.1", port=self._port, log_level="warning") + self._server = uvicorn.Server(config) + threading.Thread(target=self._server.run, daemon=True).start() + + url = f"http://127.0.0.1:{self._port}" + _wait_for_health(url) + + transport = HTTPTransport(url) + proxy = ObjectProxy(transport) + + server_ref = self._server + + def _close() -> None: + try: + transport.call("close") + except AttributeError: + pass + transport.close() + server_ref.should_exit = True + set_context_fallback(None) + + object.__setattr__(proxy, "close", _close) + return proxy diff --git a/labs/AgentStream/exgentic/src/exgentic/adapters/runners/thread.py b/labs/AgentStream/exgentic/src/exgentic/adapters/runners/thread.py new file mode 100644 index 00000000..dda6fa39 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/adapters/runners/thread.py @@ -0,0 +1,92 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""ThreadTransport — runs the object in a dedicated thread, queue-based RPC.""" + +from __future__ import annotations + +import contextvars +import queue +import threading +from typing import Any + +from .transport import ObjectHost, Transport, deserialize_error, serialize_error + +_SHUTDOWN = object() + + +class ThreadTransport(Transport): + """Runs the target object in a dedicated daemon thread. + + Communication happens via two queues (request / response). + The object's methods never block the caller's thread except + while waiting for the result. + """ + + def __init__(self, target_cls: type, *args: Any, **kwargs: Any) -> None: + self._target_cls = target_cls + self._args = args + self._kwargs = kwargs + self._req: queue.Queue = queue.Queue() + self._resp: queue.Queue = queue.Queue() + self._thread: threading.Thread | None = None + + def start(self) -> None: + ctx = contextvars.copy_context() + self._thread = threading.Thread(target=ctx.run, args=(self._worker,), daemon=True) + self._thread.start() + status, payload = self._resp.get() + if status == "error": + raise deserialize_error(payload) + + # ── worker loop ────────────────────────────────────────────────── + + def _worker(self) -> None: + try: + obj = self._target_cls(*self._args, **self._kwargs) + except Exception as exc: + self._resp.put(("error", serialize_error(exc))) + return + + host = ObjectHost(obj) + self._resp.put(("ready", None)) + + while True: + msg = self._req.get() + if msg is _SHUTDOWN: + break + op, name, args, kwargs = msg + try: + result = host.handle(op, name, *args, **kwargs) + self._resp.put(("ok", result)) + except Exception as exc: + self._resp.put(("error", serialize_error(exc))) + + # ── Transport API ──────────────────────────────────────────────── + + def _rpc(self, op: str, name: str, *args: Any, **kwargs: Any) -> Any: + if self._thread is None or not self._thread.is_alive(): + raise RuntimeError("Worker thread is not running") + self._req.put((op, name, args, kwargs)) + status, payload = self._resp.get() + if status == "error": + raise deserialize_error(payload) + return payload + + def call(self, method: str, *args: Any, **kwargs: Any) -> Any: + return self._rpc("call", method, *args, **kwargs) + + def get(self, name: str) -> Any: + return self._rpc("get", name) + + def set(self, name: str, value: Any) -> None: + self._rpc("set", name, value) + + def close(self) -> None: + if self._thread is not None and self._thread.is_alive(): + self._req.put(_SHUTDOWN) + self._thread.join(timeout=5.0) + self._thread = None + + def __repr__(self) -> str: + return f"ThreadTransport({self._target_cls.__name__})" diff --git a/labs/AgentStream/exgentic/src/exgentic/adapters/runners/transport.py b/labs/AgentStream/exgentic/src/exgentic/adapters/runners/transport.py new file mode 100644 index 00000000..fe4887d2 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/adapters/runners/transport.py @@ -0,0 +1,188 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Core transport abstractions: Transport, ObjectHost, ObjectProxy, and error helpers.""" + +from __future__ import annotations + +import builtins +import inspect +import traceback +from abc import ABC, abstractmethod +from typing import Any + +import cloudpickle as cp + +# Sentinel returned by ``get`` when the attribute is a bound method. +# The proxy checks for this to avoid serialising the entire instance. +CALLABLE_MARKER = {"__exgentic_callable__": True} + +# ── Transport interface ────────────────────────────────────────────── + + +class Transport(ABC): + """Communication channel between a proxy and a remote object. + + Every transport implements four operations so that ``ObjectProxy`` + can forward attribute access and method calls regardless of where + the real object lives. + """ + + @abstractmethod + def call(self, method: str, *args: Any, **kwargs: Any) -> Any: + ... + + @abstractmethod + def get(self, name: str) -> Any: + ... + + @abstractmethod + def set(self, name: str, value: Any) -> None: + ... + + @abstractmethod + def close(self) -> None: + ... + + +# ── ObjectHost — server side ───────────────────────────────────────── + + +class ObjectHost: + """Executes operations on a real object (the "server side"). + + Used identically whether the object lives in the same thread, + a subprocess, or an HTTP server. + """ + + def __init__(self, obj: Any) -> None: + self.obj = obj + + def handle(self, op: str, name: str, *args: Any, **kwargs: Any) -> Any: + if op == "call": + return getattr(self.obj, name)(*args, **kwargs) + if op == "get": + value = getattr(self.obj, name) + # Bound methods cannot be reliably serialised (the instance may + # contain locks, threads, etc.). Return a lightweight marker so + # the proxy knows to use ``call`` instead. + if inspect.ismethod(value) or inspect.isbuiltin(value): + return CALLABLE_MARKER + return value + if op == "set": + setattr(self.obj, name, args[0]) + return None + if op == "del": + delattr(self.obj, name) + return None + raise ValueError(f"Unknown operation: {op!r}") + + +# ── Error serialization ────────────────────────────────────────────── + + +def serialize_error(exc: BaseException) -> dict: + """Serialize an exception into a dict that can cross process/network boundaries. + + The dict always contains string fallbacks (``type``, ``msg``, ``tb``). + When possible it also includes a ``pickled`` copy of the original + exception so that custom exception types and attributes survive. + """ + pickled = None + try: + pickled = cp.dumps(exc) + except Exception: + pass + return { + "type": type(exc).__qualname__, + "msg": str(exc), + "tb": traceback.format_exc(), + "pickled": pickled, + } + + +def deserialize_error(data: dict) -> BaseException: + """Reconstruct an exception from a ``serialize_error`` dict. + + Strategy: try cloudpickle first (preserves custom types and state), + then fall back to reconstructing a builtin type from its name. + A ``__remote_traceback__`` attribute is always attached. + """ + tb = data.get("tb", "") + + # Fast path: unpickle the original exception. + pickled = data.get("pickled") + if pickled is not None: + try: + exc = cp.loads(pickled) + if isinstance(exc, BaseException): + exc.__remote_traceback__ = tb # type: ignore[attr-defined] + return exc + except Exception: + pass + + # Fallback: reconstruct from type name (builtins only) + message. + name = data.get("type", "RuntimeError") + msg = data.get("msg", "") + cls = getattr(builtins, name, None) + if not (isinstance(cls, type) and issubclass(cls, BaseException)): + cls = RuntimeError + try: + exc = cls(msg) + except TypeError: + exc = RuntimeError(f"{name}: {msg}") + exc.__remote_traceback__ = tb # type: ignore[attr-defined] + return exc + + +# ── ObjectProxy — client side ──────────────────────────────────────── + + +class ObjectProxy: + """Transparent proxy that forwards attribute access over a Transport. + + Behaves like the real object: attribute reads, writes, and method + calls are all forwarded through the transport. + """ + + def __init__(self, transport: Transport) -> None: + object.__setattr__(self, "_transport", transport) + + def __getattr__(self, name: str) -> Any: + transport: Transport = object.__getattribute__(self, "_transport") + value = transport.get(name) + if isinstance(value, dict) and value.get("__exgentic_callable__"): + + def method(*args: Any, **kwargs: Any) -> Any: + return transport.call(name, *args, **kwargs) + + method.__name__ = name # type: ignore[attr-defined] + return method + return value + + def __setattr__(self, name: str, value: Any) -> None: + if name.startswith("_"): + object.__setattr__(self, name, value) + else: + transport: Transport = object.__getattribute__(self, "_transport") + transport.set(name, value) + + def close(self) -> None: + """Close the remote object, then tear down the transport.""" + transport: Transport = object.__getattribute__(self, "_transport") + try: + transport.call("close") + except AttributeError: + pass + transport.close() + + def __del__(self) -> None: + try: + transport: Transport = object.__getattribute__(self, "_transport") + transport.close() + except Exception: + pass + + def __repr__(self) -> str: + transport: Transport = object.__getattribute__(self, "_transport") + return f"ObjectProxy({transport!r})" diff --git a/labs/AgentStream/exgentic/src/exgentic/adapters/runners/venv.py b/labs/AgentStream/exgentic/src/exgentic/adapters/runners/venv.py new file mode 100644 index 00000000..28ece872 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/adapters/runners/venv.py @@ -0,0 +1,215 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""VenvRunner — runs the HTTP service inside a uv virtual environment. + +Uses the same HTTPTransport as ServiceRunner and DockerRunner, but the +uvicorn server runs in a subprocess with its own isolated venv instead +of the host Python or a Docker container. + +The venv is created and managed by the EnvironmentManager — VenvRunner +only starts the subprocess and optionally installs extra runtime +dependencies. +""" + +from __future__ import annotations + +import atexit +import os +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Any + +from ._utils import ( + find_free_port, + inject_exgentic_env, + make_close, + prepare_subprocess_env, + serialize_kwargs, +) +from .service import HTTPTransport, _wait_for_health +from .transport import ObjectProxy + +_HEALTH_TIMEOUT = 30.0 +_TRANSPORT_TIMEOUT = 1800.0 + + +def _uv(*args: str, check: bool = True, **kwargs: Any) -> subprocess.CompletedProcess: + uv_bin = shutil.which("uv") + if uv_bin is None: + raise RuntimeError("uv CLI not found on PATH") + result = subprocess.run([uv_bin, *args], check=False, **kwargs) + if check and result.returncode != 0: + stderr = getattr(result, "stderr", "") or "" + stdout = getattr(result, "stdout", "") or "" + raise RuntimeError(f"uv {' '.join(args[:3])} failed (exit {result.returncode}):\n{stderr}\n{stdout}") + return result + + +class VenvRunner: + """Start an HTTP service in an isolated uv venv and return an ObjectProxy. + + Parameters + ---------- + target_cls: Class to instantiate inside the venv subprocess. + env_name: Environment name for EnvironmentManager (e.g. "benchmarks/bfcl"). + module_path: Dotted module path for locating package resources. + port: Host port to bind (auto-selected if None). + dependencies: Extra pip packages to install in the venv at runtime. + health_timeout: Seconds to wait for the health endpoint. + """ + + def __init__( + self, + target_cls: type | str, + *args: Any, + env_name: str = "", + module_path: str = "", + port: int | None = None, + dependencies: list[str] | None = None, + health_timeout: float | None = None, + **kwargs: Any, + ) -> None: + if args: + raise ValueError( + "VenvRunner requires keyword-only constructor arguments. " + "Pass all arguments as kwargs instead of positional args." + ) + self._target_cls = target_cls + self._kwargs = kwargs + self._env_name = env_name + self._module_path = module_path + self._port = port or find_free_port() + self._dependencies = dependencies or [] + self._health_timeout = health_timeout or _HEALTH_TIMEOUT + self._process: subprocess.Popen | None = None + + # ── venv handling ───────────────────────────────────────────────── + + def _get_venv_dir(self) -> Path: + """Return the venv directory managed by EnvironmentManager.""" + from ...environment.instance import get_manager + + return get_manager().env_path(self._env_name) / "venv" + + def _venv_python(self) -> Path: + """Return the path to the Python binary inside the venv.""" + venv = self._get_venv_dir() + if sys.platform == "win32": + return venv / "Scripts" / "python.exe" + return venv / "bin" / "python" + + def _ensure_venv(self) -> Path: + """Ensure the venv exists via EnvironmentManager.""" + from ...environment import EnvType + from ...environment.helpers import get_exgentic_install_target + from ...environment.instance import get_manager + + mgr = get_manager() + project_root, packages = get_exgentic_install_target() + mgr.install( + self._env_name, + env_type=EnvType.VENV, + module_path=self._module_path, + project_root=project_root, + packages=packages, + ) + return self._get_venv_dir() + + def _install_deps(self) -> None: + """Install extra runtime dependencies into the venv.""" + if not self._dependencies: + return + python = self._venv_python() + _uv( + "pip", + "install", + "--python", + str(python), + "--no-cache", + *self._dependencies, + capture_output=True, + text=True, + ) + + # ── subprocess lifecycle ────────────────────────────────────────── + + def start(self) -> ObjectProxy: + venv = self._ensure_venv() + self._install_deps() + + if isinstance(self._target_cls, str): + cls_ref = self._target_cls + else: + cls_ref = f"{self._target_cls.__module__}:{self._target_cls.__qualname__}" + kwargs_flag, kwargs_value = serialize_kwargs(self._kwargs) + + # Build a filtered environment (same filtering as DockerRunner). + env = prepare_subprocess_env() + env["VIRTUAL_ENV"] = str(venv) + venv_bin = str(venv / "bin") + # Prepend venv bin to the *system* PATH so external tools (docker, + # podman, git, …) remain reachable from within the venv subprocess. + system_path = os.environ.get("PATH", "") + env["PATH"] = venv_bin + os.pathsep + system_path + inject_exgentic_env(env) + + exgentic_bin = self._get_venv_dir() / "bin" / "exgentic" + cmd = [ + str(exgentic_bin), + "serve", + "--cls", + cls_ref, + kwargs_flag, + kwargs_value, + "--host", + "127.0.0.1", + "--port", + str(self._port), + ] + + self._process = subprocess.Popen( + cmd, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + atexit.register(self._stop_process) + + url = f"http://127.0.0.1:{self._port}" + try: + _wait_for_health(url, timeout=self._health_timeout) + except TimeoutError: + proc = self._process + if proc is not None: + proc.terminate() + stdout, stderr = proc.communicate(timeout=5) + else: + stdout, stderr = b"", b"" + self._stop_process() + raise TimeoutError( + f"Venv service did not become healthy within {self._health_timeout}s.\n" + f"stdout:\n{stdout.decode(errors='replace')}\n" + f"stderr:\n{stderr.decode(errors='replace')}" + ) from None + + transport = HTTPTransport(url, timeout=_TRANSPORT_TIMEOUT) + proxy = ObjectProxy(transport) + object.__setattr__(proxy, "close", make_close(transport, self._stop_process)) + return proxy + + def _stop_process(self) -> None: + if self._process is None: + return + proc = self._process + self._process = None + try: + proc.terminate() + proc.wait(timeout=5) + except Exception: + try: + proc.kill() + except Exception: + pass diff --git a/labs/AgentStream/exgentic/src/exgentic/adapters/schemas/__init__.py b/labs/AgentStream/exgentic/src/exgentic/adapters/schemas/__init__.py new file mode 100644 index 00000000..ed18cc54 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/adapters/schemas/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +__all__ = [] diff --git a/labs/AgentStream/exgentic/src/exgentic/adapters/schemas/json_schema.py b/labs/AgentStream/exgentic/src/exgentic/adapters/schemas/json_schema.py new file mode 100644 index 00000000..d2d6a7be --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/adapters/schemas/json_schema.py @@ -0,0 +1,112 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from typing import Any, Literal + +from json_schema_to_pydantic import create_model as _schema_to_model +from pydantic import BaseModel + + +def make_args_model_from_json_schema(name: str, parameters: dict[str, Any]) -> type[BaseModel]: + """Build a Pydantic v2 model from JSON Schema and verify core semantics match. + + Verifies (type/required/properties), ignoring cosmetic keys like 'title'. + """ + # 1) build + model = _schema_to_model( + schema=parameters, + base_model_type=BaseModel, + root_schema=None, + allow_undefined_array_items=False, + allow_undefined_type=False, + ) + + return model + + +def _schema_to_type(schema: dict[str, Any]) -> Any: + """Best-effort map a JSON Schema fragment to a Python type annotation.""" + if not isinstance(schema, dict): + return Any + if isinstance(schema.get("enum"), list) and schema["enum"]: + return _enum_type(schema["enum"]) # type: ignore[arg-type] + t = schema.get("type") + items = schema.get("items") if isinstance(schema.get("items"), dict) else None + return _json_type_to_py(t, items) + + +def _json_type_to_py(t: Any, item_schema: dict[str, Any] | None = None): + """Map a JSON Schema "type" to a Python type annotation. + + Supports primitives and simple containers. For arrays/objects, uses generic + fallbacks unless an item schema is provided for arrays. + """ + from typing import Any as TAny + from typing import Dict as TDict + from typing import List as TList + + if t == "string": + return str + if t == "integer": + return int + if t == "number": + return float + if t == "boolean": + return bool + if t == "array": + # Try to infer item type if provided, otherwise default to list[Any] + if isinstance(item_schema, dict): + inner = _schema_to_type(item_schema) + return TList[inner] # type: ignore[index] + return TList[TAny] # type: ignore[index] + if t == "object": + return TDict[str, TAny] # type: ignore[index] + return Any + + +def _enum_type(values: list[Any]): + """Create a Literal type from enum values when feasible; otherwise Any.""" + try: + return Literal[tuple(values)] # type: ignore[misc] + except TypeError: + # Fallback if values contain unhashables or mixed unsupported types + return Any + + +# def make_args_model_from_param_list(name: str, params: List[Dict[str, Any]]) -> type[BaseModel]: +# """Create a Pydantic model from AppWorld 'standard' parameter list entries.""" +# fields: Dict[str, Tuple[Any, Any]] = {} + +# for p in params or []: +# pname = p["name"] +# ptype = p.get("type") +# required = bool(p.get("required", False)) +# default = p.get("default", ... if required else None) +# enum_vals = p.get("enum") +# field_kwargs: Dict[str, Any] = {} + +# if isinstance(enum_vals, list) and enum_vals: +# py_t = _enum_type(enum_vals) +# else: +# py_t = _json_type_to_py(ptype) + +# if ptype in ("number", "integer"): +# if "minimum" in p: +# field_kwargs["ge"] = p["minimum"] +# if "maximum" in p: +# field_kwargs["le"] = p["maximum"] +# if ptype == "string": +# if "minLength" in p: +# field_kwargs["min_length"] = p["minLength"] +# if "maxLength" in p: +# field_kwargs["max_length"] = p["maxLength"] + +# if field_kwargs: +# annotated = Annotated[py_t, Field(**field_kwargs)] # type: ignore[misc] +# fields[pname] = (annotated, default) +# else: +# fields[pname] = (py_t, default) + +# return create_model(f"{name}_Args", **fields) diff --git a/labs/AgentStream/exgentic/src/exgentic/adapters/schemas/openai.py b/labs/AgentStream/exgentic/src/exgentic/adapters/schemas/openai.py new file mode 100644 index 00000000..2292e1ab --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/adapters/schemas/openai.py @@ -0,0 +1,67 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import create_model + +from ...core.types import ActionType, SingleAction +from .json_schema import make_args_model_from_json_schema + + +def openai_tools_to_action_types(tools: list[dict[str, Any]]) -> list[ActionType]: + """Translate OpenAI-style tools into ActionType definitions. + + Builds concrete argument models from each tool's parameter schema so no + information is lost when emitting tools back to the LLM via ActionType. + """ + actions: list[ActionType] = [] + for t in tools: + if not isinstance(t, dict) or t.get("type") != "function": + continue + fn = t.get("function") or {} + name = fn.get("name") + if not isinstance(name, str): + continue + desc = fn.get("description") or "" + params = fn.get("parameters") or {} + + args_model = make_args_model_from_json_schema(name, params) + + action_model = create_model( + f"{name}_Action", + __base__=SingleAction, + name=(Literal[name], name), + arguments=(args_model, ...), + ) + actions.append(ActionType(name=name, description=str(desc), cls=action_model)) + + if not actions: + raise ValueError("No OpenAI function tools provided to translate into ActionTypes") + return actions + + +def mcp_to_openai_tool(mcp_tool: Any) -> dict[str, Any]: + """Converts a tool definition from a 'mcp' format into OpenAI tool schema.""" + function_name = mcp_tool.name + description = mcp_tool.description or "" + parameters_schema = mcp_tool.inputSchema or {"type": "object", "properties": {}} + + if not function_name: + raise ValueError("MCP tool definition is missing a 'name'.") + + tool_schema = { + "type": "function", + "function": { + "name": function_name, + "description": description, + "parameters": parameters_schema, + }, + } + return tool_schema + + +def mcp_tools_to_openai_tools(mcp_tools: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [mcp_to_openai_tool(mcp_tool) for mcp_tool in mcp_tools] diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/__init__.py b/labs/AgentStream/exgentic/src/exgentic/agents/__init__.py new file mode 100644 index 00000000..d3ec05ef --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +# Bundled agents diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/a_mem/__init__.py b/labs/AgentStream/exgentic/src/exgentic/agents/a_mem/__init__.py new file mode 100644 index 00000000..f787a20c --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/a_mem/__init__.py @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from .a_mem_agent import AMemAgent + +__all__ = ["AMemAgent"] diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/a_mem/a_mem_agent.py b/labs/AgentStream/exgentic/src/exgentic/agents/a_mem/a_mem_agent.py new file mode 100644 index 00000000..398a108d --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/a_mem/a_mem_agent.py @@ -0,0 +1,71 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +from typing import Any, ClassVar, Optional + +from pydantic import ConfigDict + +from ...core.agent import Agent +from ...core.types import ModelSettings +from ...utils.settings import RunnerName + + +class AMemAgent(Agent): + + display_name: ClassVar[str] = "A-Mem Agent" + slug_name: ClassVar[str] = "a_mem" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + model: str = "gpt-4o" + memory_model: Optional[str] = None + + retrieve_k: int = 10 + evo_threshold: int = 100 + embedding_model: str = "all-MiniLM-L6-v2" + + shuffle_mode: str = "isolated" + + benchmark_id: Optional[str] = None + + enable_tool_shortlisting: bool = False + max_selected_tools: int = 30 + runner: RunnerName | None = None + model_settings: ModelSettings | None = None + + @classmethod + def _get_instance_class(cls): + from .a_mem_instance import AMemAgentInstance + return AMemAgentInstance + + @classmethod + def _get_instance_class_ref(cls) -> str: + return "exgentic.agents.a_mem.a_mem_instance:AMemAgentInstance" + + def _get_instance_kwargs(self, session_id: str) -> dict[str, Any]: + return { + "session_id": session_id, + "model": self.model, + "memory_model": self.memory_model or self.model, + "retrieve_k": self.retrieve_k, + "evo_threshold": self.evo_threshold, + "embedding_model": self.embedding_model, + "shuffle_mode": self.shuffle_mode, + "model_settings": self.model_settings, + "benchmark_id": self.benchmark_id, + "enable_tool_shortlisting": self.enable_tool_shortlisting, + "max_selected_tools": self.max_selected_tools, + } + + @property + def model_name(self) -> str: + return str(self.model).split("/")[-1] + + def get_models_names(self) -> list[str]: + names = [str(self.model)] + mm = self.memory_model or self.model + if mm != self.model: + names.append(str(mm)) + return names diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/a_mem/a_mem_instance.py b/labs/AgentStream/exgentic/src/exgentic/agents/a_mem/a_mem_instance.py new file mode 100644 index 00000000..4fba1fe1 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/a_mem/a_mem_instance.py @@ -0,0 +1,726 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +import json +import re +import time +from datetime import datetime +from typing import Any, Dict, List, Optional, Union + +import litellm +from litellm import ( + ChatCompletionAssistantMessage, + ChatCompletionSystemMessage, + ChatCompletionToolMessage, + ChatCompletionUserMessage, +) + +from ...core.agent_instance import AgentInstance +from ...core.types import ( + Action, + ActionType, + Message, + MessageAction, + MessageObservation, + MessagePayload, + ModelSettings, + Observation, +) +from ...utils.cost import LiteLLMCostReport + +from .memory_note import MemoryNote +from .memory_store import MemoryStore +from .prompts import GENERATE_QUERY_PROMPT, parse_keywords_response +from ..tool_shortlisting import shortlist_tools + +try: + from ...agents.litellm_tool_calling.utils import ToolCall, ToolsActionsRegistry +except ImportError: + ToolsActionsRegistry = None + ToolCall = dict + + +class AMemAgentInstance(AgentInstance): + + def __init__( + self, + session_id: str, + model: str = "gpt-4o", + memory_model: str = "gpt-4o", + retrieve_k: int = 10, + evo_threshold: int = 100, + embedding_model: str = "all-MiniLM-L6-v2", + shuffle_mode: str = "isolated", + model_settings: Optional[ModelSettings] = None, + benchmark_id: Optional[str] = None, + enable_tool_shortlisting: bool = False, + max_selected_tools: int = 30, + ) -> None: + super().__init__(session_id) + + self.model = model + self.memory_model = memory_model + self.retrieve_k = retrieve_k + self.evo_threshold = evo_threshold + self.embedding_model = embedding_model + self.shuffle_mode = shuffle_mode + self.benchmark_id = benchmark_id + self.enable_tool_shortlisting = enable_tool_shortlisting + self.max_selected_tools = max_selected_tools + + if model_settings is None: + self._model_settings = ModelSettings() + elif isinstance(model_settings, ModelSettings): + self._model_settings = model_settings + else: + self._model_settings = ModelSettings() + + self._cost = LiteLLMCostReport.initialize_empty(model_name=self.model) + self._store: Optional[MemoryStore] = None + + self.messages: list[ + Union[ + ChatCompletionAssistantMessage, + ChatCompletionToolMessage, + ChatCompletionUserMessage, + ChatCompletionSystemMessage, + ] + ] = [] + self._step_count: int = 0 + + self._registry: Optional[ToolsActionsRegistry] = None + self._all_actions: list[ActionType] = [] + + self._interaction_log: List[str] = [] + + # Memory tracking for this session + self._memories_added: int = 0 + self._evolutions_triggered: int = 0 + + def _log_failure( + self, component: str, error: Exception, context: Dict[str, Any] + ) -> None: + try: + log_path = self.paths.agent_dir / "amem_failures.jsonl" + log_path.parent.mkdir(parents=True, exist_ok=True) + entry = { + "timestamp": datetime.now().isoformat(), + "session_id": self.session_id, + "component": component, + "error_type": type(error).__name__, + "error_message": str(error)[:2000], + **{ + k: str(v)[:2000] if isinstance(v, str) else v + for k, v in context.items() + }, + } + with open(log_path, "a", encoding="utf-8") as fh: + fh.write(json.dumps(entry, ensure_ascii=False) + "\n") + except Exception: + pass + + def start( + self, + task: str, + context: Dict[str, Any], + actions: list[ActionType], + ) -> None: + super().start(task, context, actions) + + self._all_actions = list(self.actions) + if ToolsActionsRegistry is not None: + self._registry = ToolsActionsRegistry(self._all_actions) + + task_group = str( + context.get("task_group") + or context.get("task_id") + or context.get("task_name") + or "default" + ) + self._store = MemoryStore.get_or_create( + shuffle_mode=self.shuffle_mode, + task_group=task_group, + benchmark_id=self.benchmark_id, + embedding_model=self.embedding_model, + evo_threshold=self.evo_threshold, + ) + self._store.increment_session() + + system_content = self._build_system_prompt_with_memories() + self._add_message( + ChatCompletionSystemMessage(role="system", content=system_content) + ) + + content_parts: list[Any] = [] + ctx_str = "" + if self.context: + for k, v in self.context.items(): + if isinstance(v, dict) and v.get("type") == "image_url": + content_parts.append({"type": "image_url", "image_url": {"url": v["data"], "detail": "high"}}) + else: + ctx_str += f"\n<{k}>\n{v}\n" + + text_content = f"{self.task}\n{ctx_str}" + if content_parts: + content_parts.insert(0, {"type": "text", "text": text_content}) + self._add_message(ChatCompletionUserMessage(role="user", content=content_parts)) + else: + self._add_message( + ChatCompletionUserMessage(role="user", content=text_content) + ) + + self._interaction_log.append(f"Task: {self.task}") + + self.logger.info( + "A-Mem instance started store=%s session_count=%d " + "memory_count=%d benchmark=%s tools=%d", + self._store.store_id, + self._store.session_count, + self._store.memory_count, + self.benchmark_id or "(none)", + len(self._all_actions), + ) + + def react(self, observation: Optional[Observation]) -> Optional[Action]: + self._step_count += 1 + + observation_text = self._observe(observation) + if observation_text: + self._interaction_log.append( + f"Environment: {observation_text}" + ) + + tools = self._assistant_tools() + response = self._completion( + model=self.model, + messages=self.messages, + tools=tools if tools else None, + ) + + if response is None: + self.logger.error("A-Mem: LLM returned None response") + return None + + if response.usage: + self._cost.update_cost_from_tokens( + response.usage.prompt_tokens, + response.usage.completion_tokens, + ) + + choice = response["choices"][0] + message = choice["message"] + finish_reason = choice.get("finish_reason") + + if finish_reason == "tool_calls" and self._registry is not None: + tool_calls = self._extract_tool_calls(message) + self._add_message( + ChatCompletionAssistantMessage( + role="assistant", + tool_calls=[ + { + "id": tc["id"], + "type": "function", + "function": { + "name": tc["name"], + "arguments": tc["arguments"], + }, + } + for tc in tool_calls + ], + ) + ) + actions = self._registry.tool_calls_to_action(tool_calls) + + for tc in tool_calls: + self._interaction_log.append( + f"Agent action: {tc['name']}({tc['arguments']})" + ) + + self.logger.info( + "A-Mem step %d: tool_calls=%s", + self._step_count, + [tc["name"] for tc in tool_calls], + ) + return actions + else: + content = message.content if message.content else "" + + if not content: + self.logger.warning( + "A-Mem step %d: empty content response (finish_reason=%s), " + "treating as agent inability to continue", + self._step_count, finish_reason, + ) + return None + + self._add_message( + ChatCompletionAssistantMessage( + role="assistant", content=content + ) + ) + + self._interaction_log.append( + f"Agent says: {content}" + ) + + self.logger.info("A-Mem step %d: message response", self._step_count) + return MessageAction(arguments=Message(content=content)) + + def close(self) -> None: + + store = self._store + if store is None: + return + + if self._interaction_log: + self._store_session_memories() + + store.record_learning( + session_id=self.session_id, + task_id=str( + self.context.get("task_id", "") if self.context else "" + ), + memories_added=self._memories_added, + evolutions_triggered=self._evolutions_triggered, + summary=( + f"steps={self._step_count} " + f"interactions={len(self._interaction_log)} " + f"memories_added={self._memories_added} " + f"evolutions={self._evolutions_triggered}" + ), + benchmark_id=self.benchmark_id or "", + ) + + try: + cp = str(self.paths.agent_dir / "memory_checkpoint.json") + store.save_checkpoint(cp) + mt = str(self.paths.agent_dir / "memories.txt") + store.save_memories_text(mt) + except Exception as exc: + self.logger.warning("A-Mem: failed to save checkpoint: %s", exc) + + self.logger.info( + "A-Mem session closed: %d memories added, %d evolutions", + self._memories_added, + self._evolutions_triggered, + ) + + def get_cost(self) -> LiteLLMCostReport: + return self._cost + + def _build_system_prompt_with_memories(self) -> str: + + parts: List[str] = [ + "You are an expert agent that completes tasks using available tools.", + "Think step-by-step before acting.", + "Use available tools to interact with the environment.", + "When you are confident in your solution, use the finish/submit tool.", + ] + + store = self._store + if store is not None and store.memory_count > 0 and self.task: + query = self._generate_query_keywords(self.task) + memory_context = self._retrieve_memory_context(query) + if memory_context: + parts.append("") + parts.append( + "Based on the context below, complete the task. " + "Use the context to inform your decisions." + ) + parts.append("") + parts.append(f"Context:\n{memory_context}") + + return "\n".join(parts) + + def _generate_query_keywords(self, question: str) -> str: + + try: + prompt = GENERATE_QUERY_PROMPT.format(question=question) + response = self._memory_llm_call(prompt) + keywords = parse_keywords_response(response) + if keywords: + self.logger.info( + "A-Mem: generated query keywords: %s", keywords + ) + return keywords + except Exception as exc: + self.logger.warning( + "A-Mem: keyword extraction failed, using raw task: %s", exc + ) + return question + + def _retrieve_memory_context(self, query: str) -> str: + + store = self._store + if store is None: + return "" + + retrieved = store.find_related_with_neighbors(query, k=self.retrieve_k) + if not retrieved: + return "" + + + model_lower = self.model.lower() if self.model else "" + needs_budget = "gemini" in model_lower + + if needs_budget: + budget = 30000 + used = 0 + lines: List[str] = [] + for mem in retrieved: + content = mem.content + if len(content) > 5000: + content = content[:5000] + "... [truncated]" + entry = ( + f"memory content: {content} " + f"memory context: {mem.context} " + f"memory keywords: {mem.keywords} " + f"memory tags: {mem.tags}" + ) + if used + len(entry) > budget and lines: + break + lines.append(entry) + used += len(entry) + self.logger.info( + "A-Mem: injected %d/%d retrieved memories (%d chars, budget=%d)", + len(lines), len(retrieved), used, budget, + ) + else: + lines = [] + for mem in retrieved: + lines.append( + f"memory content: {mem.content} " + f"memory context: {mem.context} " + f"memory keywords: {mem.keywords} " + f"memory tags: {mem.tags}" + ) + + return "\n".join(lines) + + def _store_session_memories(self) -> None: + store = self._store + if store is None: + return + + session_content = "\n".join(self._interaction_log) + + if len(session_content.strip()) < 10: + return + + try: + note = MemoryNote.create_with_analysis( + content=session_content, + llm_call=self._memory_llm_call, + ) + + evolved = store.add_memory( + note=note, + llm_call=self._memory_llm_call, + ) + + self._memories_added += 1 + if evolved: + self._evolutions_triggered += 1 + + self.logger.info( + "A-Mem: stored session memory [%s] evolved=%s (total=%d)", + note.id[:8], + evolved, + store.memory_count, + ) + except Exception as exc: + self.logger.warning( + "A-Mem: failed to store session memory: %s", exc + ) + self._log_failure( + "session_memory_storage", exc, { + "content_preview": session_content[:500], + "interaction_count": len(self._interaction_log), + }, + ) + + def _add_message(self, message: Any) -> None: + self.logger.debug( + "Adding message: role=%s", getattr(message, "role", "?") + ) + self.messages.append(message) + + def _observe(self, observation: Optional[Observation]) -> Optional[str]: + + if observation is None: + return None + + observations = observation.to_observation_list() + if observation.is_empty(): + if not any(obs.invoking_actions for obs in observations): + return None + + collected_texts: List[str] = [] + + for obs in observations: + if isinstance(obs, MessageObservation) and isinstance( + obs.result, MessagePayload + ): + self._add_message( + ChatCompletionUserMessage( + role="user", content=obs.result.message + ) + ) + collected_texts.append(obs.result.message) + continue + + if len(obs.invoking_actions) > 0: + invoking = obs.invoking_actions[0] + if invoking.name == "message": + text = str(obs) + self._add_message( + ChatCompletionUserMessage( + role="user", content=text + ) + ) + collected_texts.append(text) + continue + + action_id = invoking.id + tool_call_id = invoking.id + if not ( + isinstance(tool_call_id, str) + and tool_call_id.startswith("call_") + ): + if self._registry is not None: + tool_call_id = ( + self._registry.action_id_to_tool_call_id.get( + action_id, tool_call_id + ) + ) + + value = obs.result + try: + content = json.dumps( + value, ensure_ascii=False, separators=(",", ":") + ) + except TypeError: + content = str(value) + + if tool_call_id is not None: + self._add_message( + ChatCompletionToolMessage( + role="tool", + tool_call_id=tool_call_id, + content=content, + ) + ) + else: + self._add_message( + ChatCompletionUserMessage( + role="user", + content=f"Tool result: {content}", + ) + ) + collected_texts.append( + f"Result of {invoking.name}: " + f"{self._summarize_for_memory(content)}" + ) + else: + text = str(obs) + self._add_message( + ChatCompletionUserMessage( + role="user", content=text + ) + ) + collected_texts.append(text) + + if collected_texts: + return "\n".join(collected_texts) + return None + + def _summarize_for_memory(self, content: str) -> str: + + if len(content) < 2000: + return content + + try: + data = json.loads(content) + except (json.JSONDecodeError, ValueError): + if len(content) > 10000: + return content[:10000] + f"\n... [truncated, total {len(content)} chars]" + return content + + if isinstance(data, str): + try: + data = json.loads(data) + except (json.JSONDecodeError, ValueError): + if len(data) > 10000: + return data[:10000] + f"\n... [truncated, total {len(data)} chars]" + return data + + if isinstance(data, list) and data and isinstance(data[0], dict): + first = data[0] + snippet_key = None + if "snippet" in first: + snippet_key = "snippet" + elif "content" in first and "docid" in first: + snippet_key = "content" + + if snippet_key is not None: + summaries: List[str] = [] + for item in data: + docid = item.get("docid", "?") + score = item.get("score") + snippet = item.get(snippet_key, "") + title = "" + if isinstance(snippet, str) and snippet.startswith("---"): + title_match = re.search(r"title:\s*(.+)", snippet) + if title_match: + title = title_match.group(1).strip() + score_str = f" score:{score:.3f}" if isinstance(score, (int, float)) else "" + snippet_preview = snippet[:400].replace("\n", " ") if isinstance(snippet, str) else str(snippet)[:400] + summaries.append( + f"[doc:{docid}{score_str}] {title} | {snippet_preview}" + ) + return "\n".join(summaries) + + max_item_chars = 200 + summaries_generic: List[str] = [] + for i, item in enumerate(data): + item_str = json.dumps(item, ensure_ascii=False, separators=(",", ":")) + if len(item_str) > max_item_chars: + item_str = item_str[:max_item_chars] + "..." + summaries_generic.append(item_str) + result = f"[{len(data)} items]\n" + "\n".join(summaries_generic) + return result + + if isinstance(data, dict) and len(content) > 5000: + compact = json.dumps(data, ensure_ascii=False, separators=(",", ":")) + if len(compact) > 5000: + return compact[:5000] + f"... [truncated, total {len(compact)} chars]" + return compact + + return content + + def _assistant_tools(self) -> list[dict[str, Any]]: + if self._registry is None: + return [] + tools = self._registry.openai_tools() + if not self.enable_tool_shortlisting: + return tools + + def _cost_cb(usage): + if usage: + self._cost.update_cost_from_tokens( + usage.prompt_tokens, usage.completion_tokens + ) + + return shortlist_tools( + tools=tools, + max_selected=self.max_selected_tools, + messages=self.messages, + completion_fn=self._completion, + model=self.model, + logger=self.logger, + cost_callback=_cost_cb, + ) + + @staticmethod + def _extract_tool_calls(message: Any) -> list[dict[str, str]]: + if not hasattr(message, "tool_calls") or not message.tool_calls: + return [] + tool_calls = [] + for tc in message.tool_calls: + tool_calls.append({ + "name": tc.function.name, + "arguments": tc.function.arguments, + "id": tc.id, + }) + return tool_calls + + def _completion(self, **kwargs) -> Any: + call_kwargs = self._model_settings.model_dump( + exclude_none=True, + exclude={"num_retries", "retry_after", "retry_strategy"}, + ) + call_kwargs.update(kwargs) + if call_kwargs.get("tools") is None: + call_kwargs.pop("tools", None) + + max_attempts = 3 + for attempt in range(max_attempts): + try: + response = litellm.completion(**call_kwargs) + choice = response["choices"][0] if response.get("choices") else None + if choice: + msg = choice.get("message") or {} + has_content = bool(msg.get("content")) + has_tools = bool(msg.get("tool_calls")) + if not has_content and not has_tools: + if attempt + 1 < max_attempts: + self.logger.warning( + "A-Mem LLM call attempt %d/%d: empty response " + "(finish_reason=%s), retrying...", + attempt + 1, max_attempts, + choice.get("finish_reason"), + ) + time.sleep(2 ** attempt) + continue + return response + except Exception as exc: + self.logger.warning( + "A-Mem LLM call attempt %d/%d failed: %s", + attempt + 1, + max_attempts, + exc, + ) + if attempt + 1 >= max_attempts: + raise + time.sleep(2 ** attempt) + return None + + def _llm_call_simple( + self, + model: str, + prompt: str, + ) -> str: + kwargs: Dict[str, Any] = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.7, + "max_tokens": 1000, + } + + max_attempts = 3 + for attempt in range(max_attempts): + try: + resp = litellm.completion(**kwargs) + if resp.usage: + self._cost.update_cost_from_tokens( + resp.usage.prompt_tokens, + resp.usage.completion_tokens, + ) + content = resp.choices[0].message.content + if content is None: + raise ValueError("LLM returned None content") + return content + except Exception as exc: + self.logger.warning( + "A-Mem simple LLM call attempt %d/%d failed: %s", + attempt + 1, + max_attempts, + exc, + ) + if attempt + 1 >= max_attempts: + self._log_failure( + "llm_call", exc, { + "model": model, + "prompt_length": len(prompt), + "prompt_preview": prompt[:500], + "attempts": max_attempts, + }, + ) + raise + time.sleep(2 ** attempt) + return "" + + def _memory_llm_call(self, prompt: str) -> str: + return self._llm_call_simple(self.memory_model, prompt) diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/a_mem/memory_note.py b/labs/AgentStream/exgentic/src/exgentic/agents/a_mem/memory_note.py new file mode 100644 index 00000000..eccc5c52 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/a_mem/memory_note.py @@ -0,0 +1,142 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +import logging +import uuid +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Callable, Dict, List, Optional + +from .prompts import ( + ANALYZE_CONTENT_PROMPT, + FOCUSED_KEYWORDS_PROMPT, + heuristic_context, + heuristic_keywords, + parse_analyze_content, + validate_analysis_result, + _parse_list_items, +) + +logger = logging.getLogger("amem") + + +@dataclass +class MemoryNote: + + content: str + id: str = "" + keywords: List[str] = field(default_factory=list) + tags: List[str] = field(default_factory=list) + context: str = "General" + links: List[int] = field(default_factory=list) + importance_score: float = 1.0 + retrieval_count: int = 0 + timestamp: str = "" + last_accessed: str = "" + evolution_history: List[Dict[str, Any]] = field(default_factory=list) + category: str = "Uncategorized" + + def __post_init__(self) -> None: + if not self.id: + self.id = str(uuid.uuid4()) + current_time = datetime.now().strftime("%Y%m%d%H%M") + if not self.timestamp: + self.timestamp = current_time + if not self.last_accessed: + self.last_accessed = current_time + # Ensure context is a string + if isinstance(self.context, list): + self.context = " ".join(self.context) + + @staticmethod + def analyze_content( + content: str, + llm_call: Callable[[str], str], + ) -> Dict[str, Any]: + prompt = ANALYZE_CONTENT_PROMPT.format(content=content) + try: + response = llm_call(prompt) + analysis = parse_analyze_content(response, content) + + # Retry focused keywords if empty + if not analysis["keywords"]: + logger.info( + "Keywords empty after initial parse -- retrying with focused prompt" + ) + retry_prompt = FOCUSED_KEYWORDS_PROMPT.format(content=content) + retry_response = llm_call(retry_prompt) + analysis["keywords"] = _parse_list_items(retry_response) + + return validate_analysis_result(analysis, content) + + except Exception as e: + logger.error("Error analyzing content: %s", e) + return { + "keywords": heuristic_keywords(content), + "context": heuristic_context(content), + "tags": heuristic_keywords(content, 3), + } + + @classmethod + def create_with_analysis( + cls, + content: str, + llm_call: Callable[[str], str], + timestamp: Optional[str] = None, + importance_score: float = 1.0, + ) -> "MemoryNote": + analysis = cls.analyze_content(content, llm_call) + return cls( + content=content, + keywords=analysis["keywords"], + context=analysis["context"], + tags=analysis["tags"], + timestamp=timestamp or "", + importance_score=importance_score, + ) + + # ------------------------------------------------------------------ + # Serialization + # ------------------------------------------------------------------ + def to_dict(self) -> Dict[str, Any]: + return { + "id": self.id, + "content": self.content, + "keywords": self.keywords, + "tags": self.tags, + "context": self.context, + "links": self.links, + "importance_score": self.importance_score, + "retrieval_count": self.retrieval_count, + "timestamp": self.timestamp, + "last_accessed": self.last_accessed, + "evolution_history": self.evolution_history, + "category": self.category, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "MemoryNote": + return cls( + id=data.get("id", ""), + content=data.get("content", ""), + keywords=data.get("keywords", []), + tags=data.get("tags", []), + context=data.get("context", "General"), + links=data.get("links", []), + importance_score=data.get("importance_score", 1.0), + retrieval_count=data.get("retrieval_count", 0), + timestamp=data.get("timestamp", ""), + last_accessed=data.get("last_accessed", ""), + evolution_history=data.get("evolution_history", []), + category=data.get("category", "Uncategorized"), + ) + + def to_retrieval_document(self) -> str: + return ( + f"content:{self.content} " + f"context:{self.context} " + f"keywords: {', '.join(self.keywords)} " + f"tags: {', '.join(self.tags)}" + ) diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/a_mem/memory_store.py b/labs/AgentStream/exgentic/src/exgentic/agents/a_mem/memory_store.py new file mode 100644 index 00000000..f67e6066 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/a_mem/memory_store.py @@ -0,0 +1,417 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +import json +import logging +import threading +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Tuple + +from .memory_note import MemoryNote +from .prompts import ( + EVOLUTION_DECISION_PROMPT, + STRENGTHEN_DETAILS_PROMPT, + UPDATE_NEIGHBORS_PROMPT, + parse_evolution_decision, + parse_strengthen_details, + parse_update_neighbors, +) +from .retriever import EmbeddingRetriever + +logger = logging.getLogger("amem") + + +@dataclass +class LearningEvent: + + session_id: str + task_id: str + step: int + memories_added: int + evolutions_triggered: int + summary: str + benchmark_id: str = "" + + +class MemoryStore: + + _instances: Dict[str, "MemoryStore"] = {} + _global_lock = threading.Lock() + + @classmethod + def get_or_create( + cls, + shuffle_mode: str = "isolated", + task_group: Optional[str] = None, + benchmark_id: Optional[str] = None, + embedding_model: str = "all-MiniLM-L6-v2", + evo_threshold: int = 100, + ) -> "MemoryStore": + if shuffle_mode == "isolated": + bm = benchmark_id or task_group or "default" + key = f"amem_isolated_{bm}" + elif shuffle_mode == "sequential": + key = "amem_sequential_global" + elif shuffle_mode == "interleaved": + key = "amem_interleaved_global" + else: + raise ValueError(f"Unknown shuffle_mode: {shuffle_mode!r}") + + with cls._global_lock: + if key not in cls._instances: + cls._instances[key] = cls( + store_id=key, + embedding_model=embedding_model, + evo_threshold=evo_threshold, + ) + return cls._instances[key] + + @classmethod + def reset_all(cls) -> None: + with cls._global_lock: + cls._instances.clear() + + @classmethod + def list_stores(cls) -> Dict[str, "MemoryStore"]: + with cls._global_lock: + return dict(cls._instances) + + def __init__( + self, + store_id: str, + embedding_model: str = "all-MiniLM-L6-v2", + evo_threshold: int = 100, + ) -> None: + self.store_id = store_id + self._lock = threading.Lock() + + self._memories: Dict[str, MemoryNote] = {} + self._retriever = EmbeddingRetriever(embedding_model) + self._embedding_model = embedding_model + self._evo_threshold = evo_threshold + self._evo_cnt: int = 0 + + self._session_count: int = 0 + self._history: List[LearningEvent] = [] + self._benchmark_counts: Dict[str, int] = {} + + + @property + def memory_count(self) -> int: + with self._lock: + return len(self._memories) + + def get_all_memories(self) -> List[MemoryNote]: + with self._lock: + return list(self._memories.values()) + + + @property + def session_count(self) -> int: + with self._lock: + return self._session_count + + def increment_session(self) -> int: + with self._lock: + self._session_count += 1 + return self._session_count + + def add_memory( + self, + note: MemoryNote, + llm_call: Callable[[str], str], + ) -> bool: + with self._lock: + evolved = self._process_and_add(note, llm_call) + return evolved + + def find_related_with_neighbors( + self, + query: str, + k: int = 10, + ) -> List[MemoryNote]: + with self._lock: + if not self._memories: + return [] + indices = self._retriever.search(query, k) + all_memories = list(self._memories.values()) + seen: set[int] = set() + results: List[MemoryNote] = [] + + for i in indices: + if i >= len(all_memories) or i in seen: + continue + seen.add(i) + note = all_memories[i] + note.retrieval_count += 1 + note.last_accessed = datetime.now().strftime("%Y%m%d%H%M") + results.append(note) + for link_idx in note.links: + if link_idx < len(all_memories) and link_idx not in seen: + seen.add(link_idx) + linked = all_memories[link_idx] + linked.retrieval_count += 1 + results.append(linked) + + return results + + def _process_and_add( + self, + note: MemoryNote, + llm_call: Callable[[str], str], + ) -> bool: + neighbor_str, indices = self._find_neighbors_for_evolution( + note.content, k=5 + ) + + evolved = False + if indices: + try: + evolved = self._run_evolution(note, neighbor_str, indices, llm_call) + except Exception as e: + logger.error( + "Evolution failed for note %s: %s -- storing without evolution", + note.id[:8], + e, + ) + + self._memories[note.id] = note + self._retriever.add_documents([note.to_retrieval_document()]) + + if evolved: + self._evo_cnt += 1 + if self._evo_cnt % self._evo_threshold == 0: + self._consolidate() + + return evolved + + def _find_neighbors_for_evolution( + self, query: str, k: int = 5 + ) -> Tuple[str, List[int]]: + if not self._memories: + return "", [] + + indices = self._retriever.search(query, k) + all_memories = list(self._memories.values()) + memory_str = "" + for i in indices: + if i >= len(all_memories): + continue + m = all_memories[i] + memory_str += ( + f"memory index:{i}" + f"\t talk start time:{m.timestamp}" + f"\t memory content: {m.content}" + f"\t memory context: {m.context}" + f"\t memory keywords: {m.keywords}" + f"\t memory tags: {m.tags}\n" + ) + return memory_str, indices + + def _run_evolution( + self, + note: MemoryNote, + neighbor_str: str, + indices: List[int], + llm_call: Callable[[str], str], + ) -> bool: + decision_prompt = EVOLUTION_DECISION_PROMPT.format( + context=note.context, + content=note.content, + keywords=note.keywords, + nearest_neighbors_memories=neighbor_str, + ) + decision_response = llm_call(decision_prompt) + decision = parse_evolution_decision(decision_response) + logger.debug("Evolution decision: %s", decision) + + if decision["decision"] == "NO_EVOLUTION": + return False + + should_strengthen = decision["decision"] in ( + "STRENGTHEN", "STRENGTHEN_AND_UPDATE" + ) + should_update = decision["decision"] in ( + "UPDATE_NEIGHBOR", "STRENGTHEN_AND_UPDATE" + ) + + if should_strengthen: + strengthen_prompt = STRENGTHEN_DETAILS_PROMPT.format( + content=note.content, + keywords=note.keywords, + nearest_neighbors_memories=neighbor_str, + ) + strengthen_response = llm_call(strengthen_prompt) + strengthen = parse_strengthen_details(strengthen_response) + logger.debug("Strengthen details: %s", strengthen) + + note.links.extend(strengthen["connections"]) + if strengthen["tags"]: + note.tags = strengthen["tags"] + + if should_update: + update_prompt = UPDATE_NEIGHBORS_PROMPT.format( + content=note.content, + context=note.context, + nearest_neighbors_memories=neighbor_str, + max_neighbor_idx=len(indices) - 1, + neighbor_count=len(indices), + ) + update_response = llm_call(update_prompt) + neighbor_updates = parse_update_neighbors( + update_response, len(indices) + ) + logger.debug("Neighbor updates: %s", neighbor_updates) + + noteslist = list(self._memories.values()) + notes_id = list(self._memories.keys()) + for i in range(min(len(indices), len(neighbor_updates))): + upd = neighbor_updates[i] + memorytmp_idx = indices[i] + if memorytmp_idx >= len(noteslist): + continue + notetmp = noteslist[memorytmp_idx] + if upd["tags"]: + notetmp.tags = upd["tags"] + if upd["context"]: + notetmp.context = upd["context"] + self._memories[notes_id[memorytmp_idx]] = notetmp + + return True + + def _consolidate(self) -> None: + logger.info( + "Consolidating memory retriever (%d memories, %d evolutions)", + len(self._memories), + self._evo_cnt, + ) + documents = [m.to_retrieval_document() for m in self._memories.values()] + self._retriever.reset(documents) + + def record_learning( + self, + session_id: str, + task_id: str, + memories_added: int, + evolutions_triggered: int, + summary: str, + benchmark_id: str = "", + ) -> None: + with self._lock: + self._history.append( + LearningEvent( + session_id=session_id, + task_id=task_id, + step=self._session_count, + memories_added=memories_added, + evolutions_triggered=evolutions_triggered, + summary=summary[:500], + benchmark_id=benchmark_id, + ) + ) + if benchmark_id: + self._benchmark_counts[benchmark_id] = ( + self._benchmark_counts.get(benchmark_id, 0) + 1 + ) + + def save_checkpoint(self, path: str) -> None: + with self._lock: + payload = { + "store_id": self.store_id, + "memory_count": len(self._memories), + "evo_cnt": self._evo_cnt, + "session_count": self._session_count, + "history_len": len(self._history), + "benchmark_counts": dict(self._benchmark_counts), + "memories": { + mid: note.to_dict() + for mid, note in self._memories.items() + }, + } + Path(path).parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2, ensure_ascii=False) + + def load_checkpoint(self, path: str) -> None: + with open(path, "r", encoding="utf-8") as fh: + payload = json.load(fh) + with self._lock: + self._evo_cnt = payload.get("evo_cnt", 0) + self._session_count = payload.get("session_count", 0) + self._benchmark_counts = payload.get("benchmark_counts", {}) + memories_data = payload.get("memories", {}) + self._memories = { + mid: MemoryNote.from_dict(mdata) + for mid, mdata in memories_data.items() + } + if self._memories: + documents = [ + m.to_retrieval_document() for m in self._memories.values() + ] + self._retriever.reset(documents) + + def save_memories_text(self, path: str) -> None: + Path(path).parent.mkdir(parents=True, exist_ok=True) + with self._lock: + lines = [ + f"# A-Mem Memory Store: {self.store_id}", + f"# Memories: {len(self._memories)}", + f"# Sessions: {self._session_count}", + f"# Evolutions: {self._evo_cnt}", + "", + ] + for i, note in enumerate(self._memories.values()): + lines.append(f"--- Memory {i + 1} [{note.id[:8]}] ---") + lines.append(f"Content: {note.content}") + lines.append(f"Context: {note.context}") + lines.append(f"Keywords: {', '.join(note.keywords)}") + lines.append(f"Tags: {', '.join(note.tags)}") + links_str = ( + ", ".join(str(l) for l in note.links) if note.links else "none" + ) + lines.append(f"Links: {links_str}") + lines.append( + f"Importance: {note.importance_score:.2f} " + f"Retrieved: {note.retrieval_count} times" + ) + lines.append("") + + with open(path, "w", encoding="utf-8") as fh: + fh.write("\n".join(lines)) + + def get_stats(self) -> Dict[str, Any]: + with self._lock: + total = len(self._memories) + if total == 0: + return { + "total_memories": 0, + "total_evolutions": self._evo_cnt, + "avg_links": 0.0, + "avg_keywords": 0.0, + "avg_importance": 0.0, + "most_retrieved": 0, + } + links_count = sum( + len(m.links) for m in self._memories.values() + ) + kw_count = sum( + len(m.keywords) for m in self._memories.values() + ) + imp_sum = sum( + m.importance_score for m in self._memories.values() + ) + max_retr = max( + m.retrieval_count for m in self._memories.values() + ) + return { + "total_memories": total, + "total_evolutions": self._evo_cnt, + "avg_links": links_count / total, + "avg_keywords": kw_count / total, + "avg_importance": imp_sum / total, + "most_retrieved": max_retr, + } diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/a_mem/prompts.py b/labs/AgentStream/exgentic/src/exgentic/agents/a_mem/prompts.py new file mode 100644 index 00000000..8b640238 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/a_mem/prompts.py @@ -0,0 +1,405 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +import json +import re +import logging +from typing import Any, Callable, Dict, List, Optional + +logger = logging.getLogger("amem") + +def strip_markdown_fences(text: str) -> str: + text = text.strip() + text = re.sub(r'^```(?:json)?\s*\n?', '', text, flags=re.MULTILINE) + text = re.sub(r'\n?\s*```$', '', text, flags=re.MULTILINE) + return text.strip() + + +def parse_with_json_fallback( + response: str, + plain_text_parser: Callable, + *parser_args, +) -> Any: + try: + cleaned = strip_markdown_fences(response) + result = json.loads(cleaned) + if isinstance(result, dict): + return result + except (json.JSONDecodeError, ValueError): + pass + return plain_text_parser(response, *parser_args) + +def _parse_list_items(text: str) -> List[str]: + if not text or not text.strip(): + return [] + + lines = text.strip().splitlines() + items: List[str] = [] + + for line in lines: + line = line.strip() + if not line: + continue + line = re.sub(r'^[\-\*\u2022]\s*', '', line) + line = re.sub(r'^\d+[\.\)]\s*', '', line) + line = line.strip().strip('"').strip("'").strip() + if not line: + continue + if ',' in line: + for part in line.split(','): + part = part.strip().strip('"').strip("'").strip() + if part: + items.append(part) + else: + items.append(line) + + return items + + +def _extract_section( + text: str, + marker: str, + next_markers: Optional[List[str]] = None, +) -> str: + pattern = re.compile( + rf'^\s*{re.escape(marker)}\s*:\s*(.*)$', + re.IGNORECASE | re.MULTILINE, + ) + match = pattern.search(text) + if not match: + return "" + + start = match.end() + first_line = match.group(1).strip() + + end = len(text) + if next_markers: + for nm in next_markers: + nm_pattern = re.compile( + rf'^\s*{re.escape(nm)}\s*:', re.IGNORECASE | re.MULTILINE + ) + nm_match = nm_pattern.search(text, start) + if nm_match and nm_match.start() < end: + end = nm_match.start() + + rest = text[start:end].strip() + if first_line and rest: + return first_line + "\n" + rest + return first_line or rest + + +_STOP_WORDS = frozenset({ + 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being', + 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', + 'should', 'may', 'might', 'shall', 'can', 'need', 'dare', 'ought', + 'used', 'to', 'of', 'in', 'for', 'on', 'with', 'at', 'by', 'from', + 'as', 'into', 'through', 'during', 'before', 'after', 'above', + 'below', 'between', 'out', 'off', 'over', 'under', 'again', + 'further', 'then', 'once', 'here', 'there', 'when', 'where', 'why', + 'how', 'all', 'both', 'each', 'few', 'more', 'most', 'other', + 'some', 'such', 'no', 'nor', 'not', 'only', 'own', 'same', 'so', + 'than', 'too', 'very', 'just', 'because', 'but', 'and', 'or', + 'if', 'while', 'about', 'up', 'it', 'its', 'i', 'me', 'my', + 'you', 'your', 'he', 'she', 'they', 'we', 'this', 'that', 'these', + 'those', 'what', 'which', 'who', 'whom', 'says', 'said', 'speaker', +}) + + +def heuristic_keywords(content: str, max_keywords: int = 5) -> List[str]: + words = re.findall(r'\b[a-zA-Z]{3,}\b', content) + scored = [] + seen: set[str] = set() + for w in words: + w_lower = w.lower() + if w_lower in _STOP_WORDS or w_lower in seen: + continue + seen.add(w_lower) + score = 2 if w[0].isupper() else 1 + scored.append((w_lower, score)) + scored.sort(key=lambda x: -x[1]) + return [w for w, _ in scored[:max_keywords]] + + +def heuristic_context(content: str) -> str: + match = re.match(r'(.+?[.!?])\s', content) + if match: + return match.group(1).strip() + return content[:200].strip() + + +ANALYZE_CONTENT_PROMPT = """\ +Analyze the following content and provide: +1. KEYWORDS: The most important keywords (nouns, verbs, key concepts). \ +Order from most to least important. At least three keywords. \ +Do not include speaker names or time references. +2. CONTEXT: One sentence summarizing the main topic, key points, and purpose. +3. TAGS: Broad categories/themes for classification (domain, format, type). \ +At least three tags. + +Respond using EXACTLY this format (one section per header): + +KEYWORDS: keyword1, keyword2, keyword3, ... +CONTEXT: A single sentence summarizing the content. +TAGS: tag1, tag2, tag3, ... + +Content for analysis: +{content}""" + + +EVOLUTION_DECISION_PROMPT = """\ +You are an AI memory evolution agent. Analyze the new memory note and its \ +nearest neighbors to decide if evolution is needed. + +New memory: +- Context: {context} +- Content: {content} +- Keywords: {keywords} + +Nearest neighbor memories: +{nearest_neighbors_memories} + +Based on the relationships between the new memory and its neighbors, decide: +- NO_EVOLUTION: The memory stands alone, no changes needed. +- STRENGTHEN: The new memory should be linked to some neighbors and its tags updated. +- UPDATE_NEIGHBOR: The neighbors' context/tags should be updated based on new understanding. +- STRENGTHEN_AND_UPDATE: Both strengthen and update neighbors. + +Respond using EXACTLY this format: +DECISION: +REASON: """ + + +STRENGTHEN_DETAILS_PROMPT = """\ +Given the new memory and its neighbors, provide updated connections and tags. + +New memory: +- Content: {content} +- Keywords: {keywords} + +Neighbor memories: +{nearest_neighbors_memories} + +Which neighbor indices should the new memory connect to? \ +What tags best describe this memory? + +Respond using EXACTLY this format: +CONNECTIONS: 0, 2, 3 +TAGS: tag1, tag2, tag3, ...""" + + +UPDATE_NEIGHBORS_PROMPT = """\ +Given the new memory and its neighbor memories, update each neighbor's \ +context and tags based on a holistic understanding of all these memories together. + +New memory: +- Content: {content} +- Context: {context} + +Neighbor memories: +{nearest_neighbors_memories} + +For each neighbor (indexed 0 to {max_neighbor_idx}), provide updated context \ +and tags. If no change is needed, repeat the original values. + +Respond using EXACTLY this format (one block per neighbor): + +NEIGHBOR 0: +CONTEXT: updated context sentence +TAGS: tag1, tag2, tag3 + +NEIGHBOR 1: +CONTEXT: updated context sentence +TAGS: tag1, tag2, tag3 + +(continue for all {neighbor_count} neighbors)""" + + +FOCUSED_KEYWORDS_PROMPT = """\ +List exactly 5 keywords that capture the main concepts of the following text. \ +Output only the keywords, comma-separated, nothing else. + +Text: {content}""" + + +GENERATE_QUERY_PROMPT = """\ +Given the following question, generate several keywords separated by commas. + +Question: {question} + +Keywords:""" + + +def parse_analyze_content(response: str, content: str = "") -> Dict[str, Any]: + def _section_parse(resp: str, content_text: str = "") -> Dict[str, Any]: + kw_text = _extract_section(resp, "KEYWORDS", ["CONTEXT", "TAGS"]) + ctx_text = _extract_section(resp, "CONTEXT", ["TAGS", "KEYWORDS"]) + tags_text = _extract_section(resp, "TAGS", ["KEYWORDS", "CONTEXT"]) + return { + "keywords": _parse_list_items(kw_text), + "context": ctx_text.strip() if ctx_text.strip() else "", + "tags": _parse_list_items(tags_text), + } + + result = parse_with_json_fallback(response, _section_parse, content) + return validate_analysis_result(result, content) + + +def parse_evolution_decision(response: str) -> Dict[str, str]: + def _section_parse(resp: str) -> Dict[str, str]: + decision_text = _extract_section(resp, "DECISION", ["REASON"]) + reason_text = _extract_section(resp, "REASON", ["DECISION"]) + + decision = decision_text.strip().upper().replace(" ", "_") + valid_decisions = { + "NO_EVOLUTION", "STRENGTHEN", "UPDATE_NEIGHBOR", + "STRENGTHEN_AND_UPDATE", + } + if decision not in valid_decisions: + resp_upper = resp.upper() + if "STRENGTHEN" in resp_upper and "UPDATE" in resp_upper: + decision = "STRENGTHEN_AND_UPDATE" + elif "STRENGTHEN" in resp_upper: + decision = "STRENGTHEN" + elif "UPDATE" in resp_upper: + decision = "UPDATE_NEIGHBOR" + else: + decision = "NO_EVOLUTION" + return {"decision": decision, "reason": reason_text.strip()} + + result = parse_with_json_fallback(response, _section_parse) + + if "should_evolve" in result: + should_evolve = result.get("should_evolve", False) + actions = result.get("actions", []) + if not should_evolve: + decision = "NO_EVOLUTION" + elif "strengthen" in actions and "update_neighbor" in actions: + decision = "STRENGTHEN_AND_UPDATE" + elif "strengthen" in actions: + decision = "STRENGTHEN" + elif "update_neighbor" in actions: + decision = "UPDATE_NEIGHBOR" + else: + decision = "NO_EVOLUTION" + result = {"decision": decision, "reason": ""} + + if "decision" not in result: + result = {"decision": "NO_EVOLUTION", "reason": ""} + + return result + + +def parse_strengthen_details(response: str) -> Dict[str, Any]: + def _section_parse(resp: str) -> Dict[str, Any]: + conn_text = _extract_section(resp, "CONNECTIONS", ["TAGS"]) + tags_text = _extract_section(resp, "TAGS", ["CONNECTIONS"]) + connections = [] + for item in _parse_list_items(conn_text): + try: + connections.append(int(item.strip())) + except (ValueError, TypeError): + pass + return {"connections": connections, "tags": _parse_list_items(tags_text)} + + result = parse_with_json_fallback(response, _section_parse) + + if "suggested_connections" in result and "connections" not in result: + result["connections"] = [ + int(x) + for x in result.get("suggested_connections", []) + if isinstance(x, (int, float)) + ] + if "tags_to_update" in result and "tags" not in result: + result["tags"] = result.get("tags_to_update", []) + + result.setdefault("connections", []) + result.setdefault("tags", []) + return result + + +def parse_update_neighbors( + response: str, num_neighbors: int +) -> List[Dict[str, Any]]: + def _section_parse( + resp: str, n_neighbors: int + ) -> List[Dict[str, Any]]: + neighbors = [] + for i in range(n_neighbors): + pattern = re.compile(rf'NEIGHBOR\s+{i}\s*:', re.IGNORECASE) + match = pattern.search(resp) + if not match: + neighbors.append({"context": "", "tags": []}) + continue + next_pattern = re.compile( + rf'NEIGHBOR\s+{i + 1}\s*:', re.IGNORECASE + ) + next_match = next_pattern.search(resp, match.end()) + block_end = next_match.start() if next_match else len(resp) + block = resp[match.end():block_end] + ctx = _extract_section(block, "CONTEXT", ["TAGS"]) + tags_text = _extract_section(block, "TAGS", ["CONTEXT"]) + neighbors.append({ + "context": ctx.strip(), + "tags": _parse_list_items(tags_text), + }) + return neighbors + + try: + cleaned = strip_markdown_fences(response) + data = json.loads(cleaned) + if isinstance(data, dict): + contexts = data.get("new_context_neighborhood", []) + tags_list = data.get("new_tags_neighborhood", []) + neighbors = [] + for i in range(num_neighbors): + ctx = contexts[i] if i < len(contexts) else "" + tags = tags_list[i] if i < len(tags_list) else [] + neighbors.append({"context": ctx, "tags": tags}) + return neighbors + except (json.JSONDecodeError, ValueError): + pass + + return _section_parse(response, num_neighbors) + + +def validate_analysis_result( + result: Dict[str, Any], content: str = "" +) -> Dict[str, Any]: + if not isinstance(result, dict): + result = {"keywords": [], "context": "", "tags": []} + + keywords = result.get("keywords", []) + context = result.get("context", "") + tags = result.get("tags", []) + + if isinstance(keywords, str): + keywords = _parse_list_items(keywords) + if isinstance(tags, str): + tags = _parse_list_items(tags) + if isinstance(context, list): + context = " ".join(context) + + if not keywords and content: + keywords = heuristic_keywords(content) + if not context and content: + context = heuristic_context(content) + if not tags and keywords: + tags = keywords[:3] + + result["keywords"] = keywords + result["context"] = context + result["tags"] = tags + return result + + +def parse_keywords_response(response: str) -> str: + try: + cleaned = strip_markdown_fences(response) + data = json.loads(cleaned) + if isinstance(data, dict) and "keywords" in data: + return str(data["keywords"]) + except (json.JSONDecodeError, ValueError): + pass + return response.strip() diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/a_mem/retriever.py b/labs/AgentStream/exgentic/src/exgentic/agents/a_mem/retriever.py new file mode 100644 index 00000000..f1fd2efb --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/a_mem/retriever.py @@ -0,0 +1,54 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +import logging +from typing import List, Optional + +import numpy as np +from sentence_transformers import SentenceTransformer +from sklearn.metrics.pairwise import cosine_similarity + +logger = logging.getLogger("amem") + + +class EmbeddingRetriever: + + def __init__(self, model_name: str = "all-MiniLM-L6-v2") -> None: + self.model = SentenceTransformer(model_name) + self.corpus: List[str] = [] + self.embeddings: Optional[np.ndarray] = None + + def add_documents(self, documents: List[str]) -> None: + if not documents: + return + + if not self.corpus: + self.corpus = list(documents) + self.embeddings = self.model.encode(documents) + else: + self.corpus.extend(documents) + new_embeddings = self.model.encode(documents) + if self.embeddings is None: + self.embeddings = new_embeddings + else: + self.embeddings = np.vstack([self.embeddings, new_embeddings]) + + def reset(self, documents: List[str]) -> None: + self.corpus = [] + self.embeddings = None + if documents: + self.add_documents(documents) + + def search(self, query: str, k: int = 5) -> List[int]: + if not self.corpus or self.embeddings is None: + return [] + + query_embedding = self.model.encode([query])[0] + similarities = cosine_similarity( + [query_embedding], self.embeddings + )[0] + k = min(k, len(self.corpus)) + top_k_indices = np.argsort(similarities)[-k:][::-1] + return top_k_indices.tolist() diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/ace/__init__.py b/labs/AgentStream/exgentic/src/exgentic/agents/ace/__init__.py new file mode 100644 index 00000000..d9dde690 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/ace/__init__.py @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from .ace_agent import ACEAgent +from .bulletpoint_analyzer import BulletpointAnalyzer, DEDUP_AVAILABLE + +__all__ = ["ACEAgent", "BulletpointAnalyzer", "DEDUP_AVAILABLE"] diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/ace/ace_agent.py b/labs/AgentStream/exgentic/src/exgentic/agents/ace/ace_agent.py new file mode 100644 index 00000000..094f1b33 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/ace/ace_agent.py @@ -0,0 +1,88 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +from typing import Any, ClassVar, Optional + +from pydantic import ConfigDict + +from ...core.agent import Agent +from ...core.types import ModelSettings +from ...utils.settings import RunnerName + + +class ACEAgent(Agent): + + display_name: ClassVar[str] = "ACE Agent" + slug_name: ClassVar[str] = "ace" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + model: str = "gpt-4o" + curator_model: Optional[str] = None + + max_num_rounds: int = 3 + curator_frequency: int = 1 + playbook_token_budget: int = 80000 + + shuffle_mode: str = "isolated" + + benchmark_id: Optional[str] = None + + initial_playbook: Optional[str] = None + initial_playbook_path: Optional[str] = None + + use_json_mode: bool = True + runner: RunnerName | None = None + model_settings: ModelSettings | None = None + + enable_tool_shortlisting: bool = False + max_selected_tools: int = 30 + + use_bulletpoint_analyzer: bool = False + bulletpoint_analyzer_threshold: float = 0.90 + + @classmethod + def _get_instance_class(cls): + from .ace_instance import ACEAgentInstance + return ACEAgentInstance + + @classmethod + def _get_instance_class_ref(cls) -> str: + return "exgentic.agents.ace.ace_instance:ACEAgentInstance" + + def _get_instance_kwargs(self, session_id: str) -> dict[str, Any]: + pb = self.initial_playbook + if pb is None and self.initial_playbook_path: + with open(self.initial_playbook_path, "r", encoding="utf-8") as fh: + pb = fh.read() + + return { + "session_id": session_id, + "model": self.model, + "curator_model": self.curator_model or self.model, + "max_num_rounds": self.max_num_rounds, + "curator_frequency": self.curator_frequency, + "playbook_token_budget": self.playbook_token_budget, + "shuffle_mode": self.shuffle_mode, + "initial_playbook": pb, + "use_json_mode": self.use_json_mode, + "model_settings": self.model_settings, + "benchmark_id": self.benchmark_id, + "use_bulletpoint_analyzer": self.use_bulletpoint_analyzer, + "bulletpoint_analyzer_threshold": self.bulletpoint_analyzer_threshold, + "enable_tool_shortlisting": self.enable_tool_shortlisting, + "max_selected_tools": self.max_selected_tools, + } + + @property + def model_name(self) -> str: + return str(self.model).split("/")[-1] + + def get_models_names(self) -> list[str]: + names = [str(self.model)] + cm = self.curator_model or self.model + if cm != self.model: + names.append(str(cm)) + return names diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/ace/ace_instance.py b/labs/AgentStream/exgentic/src/exgentic/agents/ace/ace_instance.py new file mode 100644 index 00000000..5850c363 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/ace/ace_instance.py @@ -0,0 +1,775 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +import json +import re +import time +from datetime import datetime +from typing import Any, Dict, List, Optional, Union + +import litellm +from litellm import ( + ChatCompletionAssistantMessage, + ChatCompletionSystemMessage, + ChatCompletionToolMessage, + ChatCompletionUserMessage, +) + +from ...core.agent_instance import AgentInstance +from ...core.types import ( + Action, + ActionType, + Message, + MessageAction, + MessageObservation, + MessagePayload, + ModelSettings, + Observation, +) +from ...utils.cost import LiteLLMCostReport +from ...utils.settings import get_settings + +from .playbook_store import PlaybookStore +from .playbook_utils import ( + apply_curator_operations, + extract_json_from_text, + get_playbook_stats, + update_bullet_counts, +) +from .prompts.curator import CURATOR_PROMPT_NO_GT +from .prompts.reflector import REFLECTOR_PROMPT_NO_GT +from .bulletpoint_analyzer import BulletpointAnalyzer, DEDUP_AVAILABLE +from ..tool_shortlisting import shortlist_tools + +try: + from ...agents.litellm_tool_calling.utils import ToolsActionsRegistry +except ImportError: + ToolsActionsRegistry = None + +_BULLET_ID_RE = re.compile(r"\[([a-z]{2,5}-\d{5})\]") + +settings = get_settings() + + +class ACEAgentInstance(AgentInstance): + + def __init__( + self, + session_id: str, + model: str = "gpt-4o", + curator_model: str = "gpt-4o", + max_num_rounds: int = 3, + curator_frequency: int = 1, + playbook_token_budget: int = 80000, + shuffle_mode: str = "isolated", + initial_playbook: Optional[str] = None, + use_json_mode: bool = True, + model_settings: Optional[ModelSettings] = None, + benchmark_id: Optional[str] = None, + use_bulletpoint_analyzer: bool = False, + bulletpoint_analyzer_threshold: float = 0.90, + enable_tool_shortlisting: bool = False, + max_selected_tools: int = 30, + ) -> None: + super().__init__(session_id) + + self.model = model + self.curator_model = curator_model + self.max_num_rounds = max_num_rounds + self.curator_frequency = curator_frequency + self.playbook_token_budget = playbook_token_budget + self.shuffle_mode = shuffle_mode + self.initial_playbook = initial_playbook + self.use_json_mode = use_json_mode + self.benchmark_id = benchmark_id + self.use_bulletpoint_analyzer = use_bulletpoint_analyzer + self.bulletpoint_analyzer_threshold = bulletpoint_analyzer_threshold + self.enable_tool_shortlisting = enable_tool_shortlisting + self.max_selected_tools = max_selected_tools + + if model_settings is None: + self._model_settings = ModelSettings() + elif isinstance(model_settings, ModelSettings): + self._model_settings = model_settings + else: + self._model_settings = ModelSettings() + + self._cost = LiteLLMCostReport.initialize_empty(model_name=self.model) + self._store: Optional[PlaybookStore] = None + + self.messages: list[ + Union[ + ChatCompletionAssistantMessage, + ChatCompletionToolMessage, + ChatCompletionUserMessage, + ChatCompletionSystemMessage, + ] + ] = [] + self._step_count: int = 0 + + self._registry: Optional[ToolsActionsRegistry] = None + self._all_actions: list[ActionType] = [] + + self._observation_log: List[Dict[str, Any]] = [] + self._action_log: List[Dict[str, Any]] = [] + + def _log_failure( + self, component: str, error: Exception, context: Dict[str, Any] + ) -> None: + try: + log_path = self.paths.agent_dir / "ace_failures.jsonl" + log_path.parent.mkdir(parents=True, exist_ok=True) + entry = { + "timestamp": datetime.now().isoformat(), + "session_id": self.session_id, + "component": component, + "error_type": type(error).__name__, + "error_message": str(error)[:2000], + **{k: str(v)[:2000] if isinstance(v, str) else v + for k, v in context.items()}, + } + with open(log_path, "a", encoding="utf-8") as fh: + fh.write(json.dumps(entry, ensure_ascii=False) + "\n") + except Exception: + pass + + def _log_bullet_usage(self, bullet_ids: List[str]) -> None: + store = self._store + if store is None: + return + try: + log_path = self.paths.agent_dir / "bullet_usage_log.jsonl" + log_path.parent.mkdir(parents=True, exist_ok=True) + + from .playbook_utils import extract_playbook_bullets + bullets_text = extract_playbook_bullets(store.playbook, bullet_ids) + + entry = { + "timestamp": datetime.now().isoformat(), + "session_id": self.session_id, + "task_id": str( + self.context.get("task_id", "") if self.context else "" + ), + "benchmark_id": self.benchmark_id or "", + "store_id": store.store_id, + "session_count": store.session_count, + "bullet_ids_used": bullet_ids, + "bullet_count": len(bullet_ids), + "bullets_detail": bullets_text, + "total_steps": self._step_count, + "total_observations": len(self._observation_log), + "total_actions": len(self._action_log), + "question_preview": ( + self.task if self.task else "" + ), + } + with open(log_path, "a", encoding="utf-8") as fh: + fh.write(json.dumps(entry, ensure_ascii=False) + "\n") + except Exception: + pass + + def start( + self, + task: str, + context: Dict[str, Any], + actions: list[ActionType], + ) -> None: + super().start(task, context, actions) + + self._all_actions = list(self.actions) + if ToolsActionsRegistry is not None: + self._registry = ToolsActionsRegistry(self._all_actions) + + task_group = str( + context.get("task_group") + or context.get("task_id") + or context.get("task_name") + or "default" + ) + self._store = PlaybookStore.get_or_create( + shuffle_mode=self.shuffle_mode, + task_group=task_group, + initial_playbook=self.initial_playbook, + benchmark_id=self.benchmark_id, + ) + self._store.increment_session() + + playbook = self._store.playbook + system_content = self._build_system_prompt(playbook) + self._add_message( + ChatCompletionSystemMessage(role="system", content=system_content) + ) + + content_parts: list[Any] = [] + ctx = "" + if self.context: + for k, v in self.context.items(): + if isinstance(v, dict) and v.get("type") == "image_url": + content_parts.append({"type": "image_url", "image_url": {"url": v["data"], "detail": "high"}}) + else: + ctx += f"\n<{k}>\n{v}\n" + + text_content = f"{self.task}\n{ctx}" + if content_parts: + content_parts.insert(0, {"type": "text", "text": text_content}) + self._add_message(ChatCompletionUserMessage(role="user", content=content_parts)) + else: + self._add_message( + ChatCompletionUserMessage(role="user", content=text_content) + ) + + self.logger.info( + "ACE v2 instance started store=%s session_count=%d " + "playbook_bullets=%d benchmark=%s tools=%d", + self._store.store_id, + self._store.session_count, + get_playbook_stats(playbook)["total_bullets"], + self.benchmark_id or "(none)", + len(self._all_actions), + ) + + def react(self, observation: Optional[Observation]) -> Optional[Action]: + + self._step_count += 1 + self._observe(observation) + self._log_observation(observation) + + tools = self._assistant_tools() + response = self._completion( + model=self.model, + messages=self.messages, + tools=tools if tools else None, + ) + + if response is None: + self.logger.error("ACE v2: LLM returned None response") + return None + + if response.usage: + self._cost.update_cost_from_tokens( + response.usage.prompt_tokens, + response.usage.completion_tokens, + ) + + choice = response["choices"][0] + message = choice["message"] + finish_reason = choice.get("finish_reason") + + if finish_reason == "tool_calls" and self._registry is not None: + tool_calls = self._extract_tool_calls(message) + self._add_message( + ChatCompletionAssistantMessage( + role="assistant", + tool_calls=[ + { + "id": tc["id"], + "type": "function", + "function": { + "name": tc["name"], + "arguments": tc["arguments"], + }, + } + for tc in tool_calls + ], + ) + ) + actions = self._registry.tool_calls_to_action(tool_calls) + + for tc in tool_calls: + self._action_log.append({ + "step": self._step_count, + "action": tc["name"], + "arguments": tc["arguments"], + }) + + self.logger.info("ACE v2 step %d: tool_calls=%s", self._step_count, + [tc["name"] for tc in tool_calls]) + return actions + else: + content = message.content if message.content else "" + self._add_message( + ChatCompletionAssistantMessage( + role="assistant", content=content + ) + ) + self._action_log.append({ + "step": self._step_count, + "action": "message", + "content": content, + }) + self.logger.info("ACE v2 step %d: message response", self._step_count) + return MessageAction(arguments=Message(content=content)) + + def close(self) -> None: + store = self._store + if store is None: + return + + bullet_ids = _BULLET_ID_RE.findall( + " ".join( + str(m.get("content", "")) if isinstance(m, dict) + else str(getattr(m, "content", "")) + for m in self.messages + if (isinstance(m, dict) and m.get("role") == "assistant") + or getattr(m, "role", "") == "assistant" + ) + ) + self._log_bullet_usage(bullet_ids) + + reflection_content = "(empty)" + if self._observation_log: + try: + reflection_content = self._run_post_session_reflection() + except Exception as exc: + self.logger.warning( + "ACE v2: post-session reflection failed: %s", exc + ) + self._log_failure("reflector", exc, { + "observation_count": len(self._observation_log), + "action_count": len(self._action_log), + }) + + if store.session_count % self.curator_frequency == 0: + try: + self._run_curator(reflection_content) + except Exception as exc: + self.logger.warning( + "ACE v2: curator failed: %s", exc + ) + self._log_failure("curator", exc, { + "reflection_preview": reflection_content[:500], + }) + + store.record_learning( + session_id=self.session_id, + task_id=str(self.context.get("task_id", "") if self.context else ""), + was_correct_before=False, + was_correct_after=False, + summary=( + f"steps={self._step_count} " + f"observations={len(self._observation_log)} " + f"actions={len(self._action_log)}" + ), + benchmark_id=self.benchmark_id or "", + ) + + try: + cp = str(self.paths.agent_dir / "playbook_checkpoint.json") + store.save_checkpoint(cp) + pb = str(self.paths.agent_dir / "playbook.txt") + store.save_playbook_text(pb) + except Exception as exc: + self.logger.warning("ACE v2: failed to save checkpoint: %s", exc) + + def get_cost(self) -> LiteLLMCostReport: + return self._cost + + def _build_system_prompt(self, playbook: str) -> str: + stats = get_playbook_stats(playbook) + has_content = stats["total_bullets"] > 0 + + parts = [ + "You are an expert agent that completes tasks using available tools.", + "You have access to a curated playbook of strategies and insights " + "learned from previous tasks. Use these to make better decisions.", + "", + "## Guidelines", + "- Read the playbook carefully and apply relevant strategies", + "- Pay attention to common mistakes listed and avoid them", + "- Use available tools to interact with the environment", + "- Think step-by-step before acting", + "- When you are confident in your solution, use the finish/submit tool", + "- When a playbook bullet influences your decision, mention its ID " + "(e.g. [err-00001]) in your reasoning text", + ] + + if has_content: + parts.extend([ + "", + "## Playbook (accumulated strategies & insights)", + "Each line has a bullet ID and usage stats " + "(helpful=N means it helped N times, harmful=N means it misled N times).", + "Prefer high-helpful, low-harmful bullets.", + "", + playbook, + ]) + else: + parts.extend([ + "", + "## Playbook", + "(No strategies accumulated yet. This is the first session.)", + ]) + + return "\n".join(parts) + + def _add_message(self, message: Any) -> None: + self.logger.debug("Adding message: role=%s", getattr(message, "role", "?")) + self.messages.append(message) + + def _observe(self, observation: Optional[Observation]) -> None: + if observation is None: + return + + observations = observation.to_observation_list() + if observation.is_empty(): + if not any(obs.invoking_actions for obs in observations): + return + + for obs in observations: + if isinstance(obs, MessageObservation) and isinstance( + obs.result, MessagePayload + ): + self._add_message( + ChatCompletionUserMessage( + role="user", content=obs.result.message + ) + ) + continue + + if len(obs.invoking_actions) > 0: + invoking = obs.invoking_actions[0] + if invoking.name == "message": + self._add_message( + ChatCompletionUserMessage( + role="user", content=str(obs) + ) + ) + continue + + action_id = invoking.id + tool_call_id = invoking.id + if not ( + isinstance(tool_call_id, str) + and tool_call_id.startswith("call_") + ): + if self._registry is not None: + tool_call_id = ( + self._registry.action_id_to_tool_call_id.get( + action_id, tool_call_id + ) + ) + + value = obs.result + try: + content = json.dumps( + value, ensure_ascii=False, separators=(",", ":") + ) + except TypeError: + content = str(value) + + if tool_call_id is not None: + self._add_message( + ChatCompletionToolMessage( + role="tool", + tool_call_id=tool_call_id, + content=content, + ) + ) + else: + self._add_message( + ChatCompletionUserMessage( + role="user", + content=f"Tool result: {content}", + ) + ) + else: + self._add_message( + ChatCompletionUserMessage( + role="user", content=str(obs) + ) + ) + + def _log_observation(self, observation: Optional[Observation]) -> None: + if observation is None or observation.is_empty(): + return + + for obs in observation.to_observation_list(): + result = obs.result + if result is None: + continue + + entry: Dict[str, Any] = {"step": self._step_count} + if isinstance(result, str): + entry["content"] = result + elif isinstance(result, dict): + entry["content"] = json.dumps(result, ensure_ascii=False) + else: + entry["content"] = str(result) + + if obs.invoking_actions: + entry["action"] = obs.invoking_actions[0].name + + self._observation_log.append(entry) + + def _assistant_tools(self) -> list[dict[str, Any]]: + if self._registry is None: + return [] + tools = self._registry.openai_tools() + if not self.enable_tool_shortlisting: + return tools + + def _cost_cb(usage): + if usage: + self._cost.update_cost_from_tokens( + usage.prompt_tokens, usage.completion_tokens + ) + + return shortlist_tools( + tools=tools, + max_selected=self.max_selected_tools, + messages=self.messages, + completion_fn=self._completion, + model=self.model, + logger=self.logger, + cost_callback=_cost_cb, + ) + + @staticmethod + def _extract_tool_calls(message: Any) -> list[dict[str, str]]: + if not hasattr(message, "tool_calls") or not message.tool_calls: + return [] + tool_calls = [] + for tc in message.tool_calls: + tool_calls.append({ + "name": tc.function.name, + "arguments": tc.function.arguments, + "id": tc.id, + }) + return tool_calls + + def _completion(self, **kwargs) -> Any: + call_kwargs = self._model_settings.model_dump( + exclude_none=True, + exclude={"num_retries", "retry_after", "retry_strategy"}, + ) + call_kwargs.update(kwargs) + if call_kwargs.get("tools") is None: + call_kwargs.pop("tools", None) + + max_attempts = 3 + for attempt in range(max_attempts): + try: + response = litellm.completion(**call_kwargs) + return response + except Exception as exc: + self.logger.warning( + "ACE LLM call attempt %d/%d failed: %s", + attempt + 1, + max_attempts, + exc, + ) + if attempt + 1 >= max_attempts: + raise + time.sleep(2 ** attempt) + return None + + def _llm_call_simple( + self, + model: str, + prompt: str, + *, + json_mode: bool = False, + ) -> str: + kwargs: Dict[str, Any] = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.0, + } + if json_mode: + kwargs["response_format"] = {"type": "json_object"} + + max_attempts = 3 + for attempt in range(max_attempts): + try: + resp = litellm.completion(**kwargs) + if resp.usage: + self._cost.update_cost_from_tokens( + resp.usage.prompt_tokens, + resp.usage.completion_tokens, + ) + content = resp.choices[0].message.content + if content is None: + raise ValueError("LLM returned None content") + return content + except Exception as exc: + self.logger.warning( + "ACE simple LLM call attempt %d/%d failed: %s", + attempt + 1, + max_attempts, + exc, + ) + if attempt + 1 >= max_attempts: + self._log_failure( + "llm_call", exc, { + "model": model, + "prompt_length": len(prompt), + "prompt_preview": prompt[:500], + "attempts": max_attempts, + }, + ) + raise + time.sleep(2 ** attempt) + return "" + + def _run_post_session_reflection(self) -> str: + store = self._store + assert store is not None + + playbook = store.playbook + session_trace = self._build_session_trace() + + prompt = REFLECTOR_PROMPT_NO_GT.format( + question=self.task if self.task else "", + reasoning_trace=session_trace, + predicted_answer="(see session trace above)", + bullets_used=playbook, + ) + + raw = self._llm_call_simple( + self.model, prompt, json_mode=self.use_json_mode + ) + + bullet_tags: List[Dict] = [] + reflection_text = raw + parsed = extract_json_from_text(raw) + if parsed and isinstance(parsed, dict): + bullet_tags = parsed.get("bullet_tags", []) + reflection_text = parsed.get("reasoning", raw) + else: + self.logger.warning( + "ACE Reflector: JSON parse failed, raw length=%d", len(raw) + ) + self._log_failure( + "reflector_parse", ValueError("JSON parse failed"), { + "raw_response_preview": raw[:1000], + }, + ) + + if bullet_tags: + store.playbook = update_bullet_counts(store.playbook, bullet_tags) + + self.logger.info( + "ACE post-session reflection: %d bullet tags updated", + len(bullet_tags), + ) + + return reflection_text + + def _build_session_trace(self) -> str: + events: List[Dict[str, Any]] = [] + for entry in self._action_log: + events.append({"type": "action", **entry}) + for entry in self._observation_log: + events.append({"type": "observation", **entry}) + + events.sort(key=lambda e: (e.get("step", 0), 0 if e["type"] == "action" else 1)) + + lines: List[str] = [] + for event in events: + step = event.get("step", "?") + if event["type"] == "action": + action = event.get("action", "?") + args = event.get("arguments", event.get("content", "")) + lines.append(f"[Step {step}] Action: {action}") + if args: + lines.append(f" Args: {str(args)}") + else: + action = event.get("action", "env") + content = event.get("content", "") + lines.append(f"[Step {step}] Observation from {action}:") + lines.append(f" {content}") + + return "\n".join(lines) if lines else "(No session trace recorded)" + + def _run_curator(self, reflection_content: str) -> None: + store = self._store + assert store is not None + + playbook = store.playbook + stats = get_playbook_stats(playbook) + + question_context = self.task if self.task else "" + if self.context: + question_context += "".join( + f"\n<{k}>\n{v}\n" + for k, v in self.context.items() + ) + + prompt = CURATOR_PROMPT_NO_GT.format( + token_budget=self.playbook_token_budget, + current_step=store.session_count, + total_samples="ongoing", + playbook_stats=json.dumps(stats, indent=2), + recent_reflection=reflection_content, + current_playbook=playbook, + question_context=question_context, + ) + + raw = self._llm_call_simple( + self.curator_model, prompt, json_mode=self.use_json_mode + ) + + if raw.startswith("INCORRECT_DUE_TO_EMPTY_RESPONSE"): + self.logger.warning("ACE Curator: skipping due to empty response") + self._log_failure( + "curator_empty_response", ValueError("empty LLM response"), { + "session_count": store.session_count, + }, + ) + return + + parsed = extract_json_from_text(raw) + if parsed and isinstance(parsed, dict): + if "operations" not in parsed or not isinstance(parsed["operations"], list): + self.logger.warning("ACE Curator: missing or invalid 'operations' field") + self._log_failure( + "curator_schema", ValueError("missing 'operations' list"), { + "raw_response_preview": raw[:1000], + "parsed_keys": list(parsed.keys()), + }, + ) + return + + ops = parsed["operations"] + valid_ops = [] + for op in ops: + if not isinstance(op, dict) or "type" not in op: + continue + if op["type"] == "ADD": + if "section" in op and "content" in op: + valid_ops.append(op) + else: + valid_ops.append(op) + + if valid_ops: + new_playbook, new_id = apply_curator_operations( + playbook, valid_ops, store.next_global_id + ) + store.playbook = new_playbook + store.next_global_id = new_id + self.logger.info("ACE Curator: applied %d operations", len(valid_ops)) + + if self.use_bulletpoint_analyzer and DEDUP_AVAILABLE: + self.logger.info( + "ACE BulletpointAnalyzer: running (threshold=%.2f)", + self.bulletpoint_analyzer_threshold, + ) + analyzer = BulletpointAnalyzer( + llm_merge_fn=lambda p: self._llm_call_simple( + self.curator_model, p, json_mode=False + ), + ) + store.playbook = analyzer.analyze( + playbook=store.playbook, + threshold=self.bulletpoint_analyzer_threshold, + merge=True, + ) + else: + self.logger.warning("ACE Curator: failed to parse response") + self._log_failure( + "curator_parse", ValueError("JSON parse failed"), { + "raw_response_preview": raw[:1000], + }, + ) diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/ace/bulletpoint_analyzer.py b/labs/AgentStream/exgentic/src/exgentic/agents/ace/bulletpoint_analyzer.py new file mode 100644 index 00000000..30636587 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/ace/bulletpoint_analyzer.py @@ -0,0 +1,200 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +import re +from typing import Any, Dict, List, Optional, Tuple + +try: + import numpy as np + from sentence_transformers import SentenceTransformer + import faiss + + DEDUP_AVAILABLE = True +except ImportError: + DEDUP_AVAILABLE = False + np = None + +from .playbook_utils import parse_playbook_line, format_playbook_line + + +class BulletpointAnalyzer: + + def __init__( + self, + llm_merge_fn: Optional[Any] = None, + embedding_model_name: str = "all-mpnet-base-v2", + ) -> None: + self.llm_merge_fn = llm_merge_fn + self.embedding_model_name = embedding_model_name + self._embedding_model: Optional[Any] = None + + def _load_embedding_model(self) -> None: + if self._embedding_model is None and DEDUP_AVAILABLE: + self._embedding_model = SentenceTransformer(self.embedding_model_name) + + @staticmethod + def _parse_playbook( + playbook: str, + ) -> Tuple[List[str], List[Dict[str, Any]], Dict[int, int]]: + lines = playbook.strip().split("\n") + bullets: List[Dict[str, Any]] = [] + bullet_line_mapping: Dict[int, int] = {} + + for line_idx, line in enumerate(lines): + parsed = parse_playbook_line(line) + if parsed: + parsed["line_number"] = line_idx + 1 + parsed["original_line"] = line + bullet_index = len(bullets) + bullet_line_mapping[bullet_index] = line_idx + bullets.append(parsed) + + return lines, bullets, bullet_line_mapping + + def _compute_embeddings(self, bullets: List[Dict[str, Any]]) -> Any: + if not DEDUP_AVAILABLE: + raise RuntimeError("Cannot compute embeddings without sentence-transformers") + self._load_embedding_model() + contents = [b["content"] for b in bullets] + embeddings = self._embedding_model.encode( + contents, convert_to_numpy=True, show_progress_bar=False + ) + faiss.normalize_L2(embeddings) + return embeddings + + @staticmethod + def _find_similar_groups( + bullets: List[Dict[str, Any]], + embeddings: Any, + threshold: float, + ) -> List[Dict[str, Any]]: + similarity_matrix = np.dot(embeddings, embeddings.T) + duplicate_groups: List[Dict[str, Any]] = [] + visited: set[int] = set() + + for i in range(len(bullets)): + if i in visited: + continue + similar_indices = [] + for j in range(i + 1, len(bullets)): + if similarity_matrix[i, j] >= threshold: + similar_indices.append(j) + if similar_indices: + group = [i] + similar_indices + duplicate_groups.append( + {"indices": group, "bullets": [bullets[idx] for idx in group]} + ) + visited.update(group) + + return duplicate_groups + + def _merge_bullets_with_llm( + self, bullets_group: List[Dict[str, Any]] + ) -> Optional[Dict[str, Any]]: + if len(bullets_group) == 1: + return bullets_group[0] + + if self.llm_merge_fn is None: + return bullets_group[0] + + bullets_text = "\n".join( + f"{i+1}. [{b['id']}] helpful={b['helpful']} harmful={b['harmful']} :: {b['content']}" + for i, b in enumerate(bullets_group) + ) + total_helpful = sum(b["helpful"] for b in bullets_group) + total_harmful = sum(b["harmful"] for b in bullets_group) + base_id = bullets_group[0]["id"] + + prompt = ( + f"You are merging similar playbook bulletpoints into a single, " + f"comprehensive entry.\n\n" + f"Given these similar bulletpoints:\n{bullets_text}\n\n" + f"Merge them into ONE bulletpoint that captures all important " + f"information while removing redundancy.\n\n" + f"Requirements:\n" + f"1. Keep the ID from the first entry: [{base_id}]\n" + f"2. Use combined counts: helpful={total_helpful} harmful={total_harmful}\n" + f"3. Combine the content to be comprehensive but concise\n" + f"4. Output ONLY in this format: [{base_id}] helpful={total_helpful} " + f"harmful={total_harmful} :: [merged content]\n\n" + f"Do NOT include any explanation, just output the merged bulletpoint." + ) + + try: + merged_content = self.llm_merge_fn(prompt).strip() + pattern = r"\[([^\]]+)\]\s+helpful=(\d+)\s+harmful=(\d+)\s+::\s+(.+)" + match = re.match(pattern, merged_content) + if match: + bullet_id, helpful, harmful, content = match.groups() + return { + "id": bullet_id, + "helpful": int(helpful), + "harmful": int(harmful), + "content": content.strip(), + "original_line": format_playbook_line( + bullet_id, int(helpful), int(harmful), content.strip() + ), + "is_merged": True, + "original_count": len(bullets_group), + } + else: + return bullets_group[0] + except Exception: + return bullets_group[0] + + def analyze( + self, + playbook: str, + threshold: float = 0.90, + merge: bool = True, + ) -> str: + if not DEDUP_AVAILABLE: + return playbook + + original_lines, bullets, bullet_line_mapping = self._parse_playbook(playbook) + + if len(bullets) == 0: + return playbook + + embeddings = self._compute_embeddings(bullets) + duplicate_groups = self._find_similar_groups(bullets, embeddings, threshold) + + if len(duplicate_groups) == 0: + return playbook + + merge_mapping: Dict[int, Dict[str, Any]] = {} + processed_indices: set[int] = set() + + if merge: + for group in duplicate_groups: + indices = group["indices"] + merged_bullet = self._merge_bullets_with_llm(group["bullets"]) + if merged_bullet: + merge_mapping[indices[0]] = merged_bullet + processed_indices.update(indices) + else: + for group in duplicate_groups: + indices = group["indices"] + processed_indices.update(indices[1:]) + + output_lines: List[str] = [] + for line_idx, original_line in enumerate(original_lines): + current_bullet_idx = None + for bi, li in bullet_line_mapping.items(): + if li == line_idx: + current_bullet_idx = bi + break + + if current_bullet_idx is not None: + if current_bullet_idx in merge_mapping: + output_lines.append(merge_mapping[current_bullet_idx]["original_line"]) + elif current_bullet_idx in processed_indices: + continue + else: + output_lines.append(original_line) + else: + output_lines.append(original_line) + + return "\n".join(output_lines) diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/ace/playbook_store.py b/labs/AgentStream/exgentic/src/exgentic/agents/ace/playbook_store.py new file mode 100644 index 00000000..61bf26ce --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/ace/playbook_store.py @@ -0,0 +1,172 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +import json +import threading +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Optional + + +DEFAULT_PLAYBOOK = """\ +## STRATEGIES & INSIGHTS + +## FORMULAS & CALCULATIONS + +## CODE SNIPPETS & TEMPLATES + +## COMMON MISTAKES TO AVOID + +## PROBLEM-SOLVING HEURISTICS + +## CONTEXT CLUES & INDICATORS + +## OTHERS""" + + +@dataclass +class LearningEvent: + session_id: str + task_id: str + step: int + was_correct_before: bool + was_correct_after: bool + summary: str + benchmark_id: str = "" + + +class PlaybookStore: + + _instances: Dict[str, "PlaybookStore"] = {} + _global_lock = threading.Lock() + + @classmethod + def get_or_create( + cls, + shuffle_mode: str = "isolated", + task_group: Optional[str] = None, + initial_playbook: Optional[str] = None, + benchmark_id: Optional[str] = None, + ) -> "PlaybookStore": + if shuffle_mode == "isolated": + bm = benchmark_id or task_group or "default" + key = f"ace_isolated_{bm}" + elif shuffle_mode == "sequential": + key = "ace_sequential_global" + elif shuffle_mode == "interleaved": + key = "ace_interleaved_global" + else: + raise ValueError(f"Unknown shuffle_mode: {shuffle_mode!r}") + + with cls._global_lock: + if key not in cls._instances: + cls._instances[key] = cls( + store_id=key, + initial_playbook=initial_playbook or DEFAULT_PLAYBOOK, + ) + return cls._instances[key] + + @classmethod + def reset_all(cls) -> None: + with cls._global_lock: + cls._instances.clear() + + @classmethod + def list_stores(cls) -> Dict[str, "PlaybookStore"]: + with cls._global_lock: + return dict(cls._instances) + + def __init__(self, store_id: str, initial_playbook: str) -> None: + self.store_id = store_id + self._lock = threading.Lock() + self._playbook: str = initial_playbook + self._next_global_id: int = 1 + self._session_count: int = 0 + self._history: List[LearningEvent] = [] + self._benchmark_counts: Dict[str, int] = {} + + @property + def playbook(self) -> str: + with self._lock: + return self._playbook + + @playbook.setter + def playbook(self, value: str) -> None: + with self._lock: + self._playbook = value + + @property + def next_global_id(self) -> int: + with self._lock: + return self._next_global_id + + @next_global_id.setter + def next_global_id(self, value: int) -> None: + with self._lock: + self._next_global_id = value + + @property + def session_count(self) -> int: + with self._lock: + return self._session_count + + def increment_session(self) -> int: + with self._lock: + self._session_count += 1 + return self._session_count + + def record_learning( + self, + session_id: str, + task_id: str, + was_correct_before: bool, + was_correct_after: bool, + summary: str, + benchmark_id: str = "", + ) -> None: + with self._lock: + self._history.append( + LearningEvent( + session_id=session_id, + task_id=task_id, + step=self._session_count, + was_correct_before=was_correct_before, + was_correct_after=was_correct_after, + summary=summary[:500], + benchmark_id=benchmark_id, + ) + ) + if benchmark_id: + self._benchmark_counts[benchmark_id] = ( + self._benchmark_counts.get(benchmark_id, 0) + 1 + ) + + def save_checkpoint(self, path: str) -> None: + with self._lock: + payload = { + "store_id": self.store_id, + "playbook": self._playbook, + "next_global_id": self._next_global_id, + "session_count": self._session_count, + "history_len": len(self._history), + "benchmark_counts": dict(self._benchmark_counts), + } + Path(path).parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2, ensure_ascii=False) + + def load_checkpoint(self, path: str) -> None: + with open(path, "r", encoding="utf-8") as fh: + payload = json.load(fh) + with self._lock: + self._playbook = payload["playbook"] + self._next_global_id = payload["next_global_id"] + self._session_count = payload.get("session_count", 0) + self._benchmark_counts = payload.get("benchmark_counts", {}) + + def save_playbook_text(self, path: str) -> None: + Path(path).parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + fh.write(self.playbook) diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/ace/playbook_utils.py b/labs/AgentStream/exgentic/src/exgentic/agents/ace/playbook_utils.py new file mode 100644 index 00000000..fa1f915b --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/ace/playbook_utils.py @@ -0,0 +1,230 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +import json +import re +from typing import Any, Dict, List, Optional, Tuple + +_SLUG_MAP = { + "strategies_and_insights": "str", + "formulas_and_calculations": "calc", + "code_snippets_and_templates": "code", + "common_mistakes_to_avoid": "err", + "problem_solving_heuristics": "prob", + "context_clues_and_indicators": "ctx", + "others": "misc", + "meta_strategies": "meta", +} + + +def get_section_slug(section_name: str) -> str: + clean = section_name.lower().strip().replace(" ", "_").replace("&", "and") + if clean in _SLUG_MAP: + return _SLUG_MAP[clean] + words = clean.split("_") + if len(words) == 1: + return words[0][:4] + return "".join(w[0] for w in words[:5]) + + +_LINE_RE = re.compile( + r"\[([^\]]+)\]\s*helpful=(\d+)\s*harmful=(\d+)\s*::\s*(.*)" +) + + +def parse_playbook_line(line: str) -> Optional[Dict[str, Any]]: + m = _LINE_RE.match(line.strip()) + if m: + return { + "id": m.group(1), + "helpful": int(m.group(2)), + "harmful": int(m.group(3)), + "content": m.group(4), + "raw_line": line, + } + return None + + +def format_playbook_line( + bullet_id: str, helpful: int, harmful: int, content: str +) -> str: + return f"[{bullet_id}] helpful={helpful} harmful={harmful} :: {content}" + +def update_bullet_counts(playbook_text: str, bullet_tags: List[Dict]) -> str: + tag_map: Dict[str, str] = {} + for tag in bullet_tags: + if not isinstance(tag, dict): + continue + bid = tag.get("id") or tag.get("bullet", "") + tval = tag.get("tag", "neutral") + if bid: + tag_map[bid] = tval + + if not tag_map: + return playbook_text + + lines = playbook_text.split("\n") + updated: List[str] = [] + for line in lines: + parsed = parse_playbook_line(line) + if parsed and parsed["id"] in tag_map: + t = tag_map[parsed["id"]] + if t == "helpful": + parsed["helpful"] += 1 + elif t == "harmful": + parsed["harmful"] += 1 + updated.append( + format_playbook_line( + parsed["id"], parsed["helpful"], parsed["harmful"], parsed["content"] + ) + ) + else: + updated.append(line) + return "\n".join(updated) + + +def apply_curator_operations( + playbook_text: str, + operations: List[Dict[str, Any]], + next_id: int, +) -> Tuple[str, int]: + lines = playbook_text.split("\n") + sections: Dict[str, int] = {} + for i, line in enumerate(lines): + if line.strip().startswith("##"): + header = line.strip()[2:].strip() + norm = header.lower().replace(" ", "_").replace("&", "and") + sections[norm] = i + + bullets_to_add: List[Tuple[str, str]] = [] + + for op in operations: + if op.get("type") != "ADD": + continue + section_raw = op.get("section", "others") + section_norm = section_raw.lower().replace(" ", "_").replace("&", "and") + if section_norm not in sections: + section_norm = "others" + + slug = get_section_slug(section_norm) + new_id = f"{slug}-{next_id:05d}" + next_id += 1 + content = op.get("content", "") + new_line = format_playbook_line(new_id, 0, 0, content) + bullets_to_add.append((section_norm, new_line)) + + final: List[str] = [] + current_section: Optional[str] = None + + for line in lines: + if line.strip().startswith("##"): + if current_section is not None: + for sec, bline in bullets_to_add: + if sec == current_section: + final.append(bline) + bullets_to_add = [ + (s, b) for s, b in bullets_to_add if s != current_section + ] + header = line.strip()[2:].strip() + current_section = header.lower().replace(" ", "_").replace("&", "and") + final.append(line) + + if current_section is not None: + for sec, bline in bullets_to_add: + if sec == current_section: + final.append(bline) + bullets_to_add = [(s, b) for s, b in bullets_to_add if s != current_section] + + for _, bline in bullets_to_add: + final.append(bline) + + return "\n".join(final), next_id + +def get_playbook_stats(playbook_text: str) -> Dict[str, Any]: + stats: Dict[str, Any] = { + "total_bullets": 0, + "high_performing": 0, + "problematic": 0, + "unused": 0, + "by_section": {}, + } + current_section = "general" + for line in playbook_text.split("\n"): + if line.strip().startswith("##"): + current_section = line.strip()[2:].strip() + continue + parsed = parse_playbook_line(line) + if parsed: + stats["total_bullets"] += 1 + h, d = parsed["helpful"], parsed["harmful"] + if h > 5 and d < 2: + stats["high_performing"] += 1 + elif d >= h and d > 0: + stats["problematic"] += 1 + elif h + d == 0: + stats["unused"] += 1 + sec = stats["by_section"].setdefault( + current_section, {"count": 0, "helpful": 0, "harmful": 0} + ) + sec["count"] += 1 + sec["helpful"] += h + sec["harmful"] += d + return stats + +def extract_playbook_bullets( + playbook_text: str, bullet_ids: List[str] +) -> str: + if not bullet_ids: + return "(No bullets used by generator)" + found: List[str] = [] + for line in playbook_text.split("\n"): + parsed = parse_playbook_line(line) + if parsed and parsed["id"] in bullet_ids: + found.append( + format_playbook_line( + parsed["id"], parsed["helpful"], parsed["harmful"], parsed["content"] + ) + ) + return "\n".join(found) if found else "(No matching bullets found)" + + +def extract_json_from_text(text: str) -> Optional[Dict[str, Any]]: + try: + return json.loads(text.strip()) + except json.JSONDecodeError: + pass + + for m in re.finditer(r"```json\s*(.*?)\s*```", text, re.DOTALL | re.I): + try: + return json.loads(m.group(1).strip()) + except json.JSONDecodeError: + continue + + i = 0 + while i < len(text): + if text[i] == "{": + depth, start = 1, i + i += 1 + while i < len(text) and depth > 0: + if text[i] == "{": + depth += 1 + elif text[i] == "}": + depth -= 1 + elif text[i] == '"': + i += 1 + while i < len(text) and text[i] != '"': + if text[i] == "\\": + i += 1 + i += 1 + i += 1 + if depth == 0: + try: + return json.loads(text[start:i]) + except json.JSONDecodeError: + pass + else: + i += 1 + + return None diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/ace/prompts/__init__.py b/labs/AgentStream/exgentic/src/exgentic/agents/ace/prompts/__init__.py new file mode 100644 index 00000000..125af47f --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/ace/prompts/__init__.py @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from .reflector import REFLECTOR_PROMPT_NO_GT +from .curator import CURATOR_PROMPT_NO_GT + +__all__ = [ + "REFLECTOR_PROMPT_NO_GT", + "CURATOR_PROMPT_NO_GT", +] diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/ace/prompts/curator.py b/labs/AgentStream/exgentic/src/exgentic/agents/ace/prompts/curator.py new file mode 100644 index 00000000..02c217ff --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/ace/prompts/curator.py @@ -0,0 +1,67 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +CURATOR_PROMPT_NO_GT = """\ +You are a master curator of knowledge. Your job is to identify what new insights \ +should be added to an existing playbook based on a reflection from a previous attempt. + +**Context:** +- The playbook you created will be used to help answering similar questions. +- The reflection is generated using environment feedback that will NOT be available \ +when the playbook is being used. + +**CRITICAL: You MUST respond with valid JSON only. Do not use markdown formatting or code blocks.** + +**Instructions:** +- Review the existing playbook and the reflection from the previous attempt +- Identify ONLY the NEW insights, strategies, or mistakes that are MISSING from the current playbook +- Avoid redundancy - if similar advice already exists, only add new content that is a perfect complement to the existing playbook +- Do NOT regenerate the entire playbook - only provide the additions needed +- Focus on quality over quantity - a focused, well-organized playbook is better than an exhaustive one +- Format your response as a PURE JSON object with specific sections +- For any operation if no new content to add, return an empty list for the operations field +- Be concise and specific - each addition should be actionable + + +**Training Context:** +- Total token budget: {token_budget} tokens +- Training progress: Sample {current_step} out of {total_samples} + +**Current Playbook Stats:** +{playbook_stats} + +**Recent Reflection:** +{recent_reflection} + +**Current Playbook:** +{current_playbook} + +**Question Context:** +{question_context} + +**Your Task:** +Output ONLY a valid JSON object with these exact fields: +- reasoning: your chain of thought / reasoning / thinking process +- operations: a list of operations to be performed on the playbook + - type: the type of operation to be performed + - section: the section to add the bullet to + - content: the new content of the bullet + +**Available Operations:** +1. ADD: Create new bullet points with fresh IDs + - section: the section to add the new bullet to + - content: the new content of the bullet. Note: no need to include the bullet_id \ +in the content like '[ctx-00263] helpful=1 harmful=0 ::', the bullet_id will be added by the system. + +**RESPONSE FORMAT - Output ONLY this JSON structure (no markdown, no code blocks):** +{{ + "reasoning": "[Your reasoning here]", + "operations": [ + {{ + "type": "ADD", + "section": "formulas_and_calculations", + "content": "[New calculation method...]" + }} + ] +}} +""" diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/ace/prompts/reflector.py b/labs/AgentStream/exgentic/src/exgentic/agents/ace/prompts/reflector.py new file mode 100644 index 00000000..e2b3a2e0 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/ace/prompts/reflector.py @@ -0,0 +1,53 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +REFLECTOR_PROMPT_NO_GT = """\ +You are an expert analyst and educator. Your job is to analyze a model's \ +reasoning process and identify potential issues or strengths based on the \ +reasoning trace alone. + +**Instructions:** +- Carefully analyze the model's reasoning trace to evaluate its approach +- The reasoning trace includes both the model's actions and environment observations in chronological order +- Identify potential conceptual errors, calculation mistakes, or misapplied strategies +- Also note what the model did well +- Provide actionable insights that could help the model perform better in future tasks +- Focus on the root cause, not just surface-level observations +- Be specific about what could be improved +- You will receive the full playbook that was available to the agent. +- Based on the reasoning trace, infer which bullets the agent likely applied or was influenced by, and tag each relevant bullet as 'helpful', 'harmful', or 'neutral'. Skip unrelated bullets. + +Your output should be a json object, which contains the following fields + - reasoning: your chain of thought / reasoning / thinking process, detailed analysis and calculations + - error_identification: what potential issues exist in the reasoning? (or "none identified" if the approach appears sound) + - root_cause_analysis: why might these issues occur? What concept may have been misunderstood? + - correct_approach: what could the model do differently or better? + - key_insight: what strategy, formula, or principle should be remembered for future tasks? + - bullet_tags: a list of json objects with bullet id and tag for each relevant playbook bullet + + +**Question:** +{question} + +**Model's Reasoning Trace:** +{reasoning_trace} + +**Model's Predicted Answer:** +{predicted_answer} + +**Full Playbook:** +{bullets_used} + +**Answer in this exact JSON format:** +{{ + "reasoning": "[Your chain of thought / reasoning / thinking process]", + "error_identification": "[What potential issues exist in the reasoning?]", + "root_cause_analysis": "[Why might these issues occur?]", + "correct_approach": "[What could the model do differently or better?]", + "key_insight": "[What strategy or principle should be remembered?]", + "bullet_tags": [ + {{"id": "calc-00001", "tag": "helpful"}}, + {{"id": "fin-00002", "tag": "harmful"}} + ] +}} +""" diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/autoskill/__init__.py b/labs/AgentStream/exgentic/src/exgentic/agents/autoskill/__init__.py new file mode 100644 index 00000000..17ea7313 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/autoskill/__init__.py @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from .autoskill_agent import AutoSkillAgent +from .skill_store import SkillStore + +__all__ = ["AutoSkillAgent", "SkillStore"] diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/autoskill/autoskill_agent.py b/labs/AgentStream/exgentic/src/exgentic/agents/autoskill/autoskill_agent.py new file mode 100644 index 00000000..ec0d498a --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/autoskill/autoskill_agent.py @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +from typing import Any, ClassVar, Optional + +from pydantic import ConfigDict + +from ...core.agent import Agent +from ...core.types import ModelSettings +from ...utils.settings import RunnerName + + +class AutoSkillAgent(Agent): + + display_name: ClassVar[str] = "AutoSkill Agent" + slug_name: ClassVar[str] = "autoskill" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + + model: str = "gpt-4o" + skill_model: Optional[str] = None + + retrieve_k: int = 3 + retrieval_threshold: float = 0.4 + bm25_weight: float = 0.1 + dedupe_similarity_threshold: float = 0.4 + embedding_model: str = "all-MiniLM-L6-v2" + enable_query_rewrite: bool = True + max_context_chars: int = 6000 + + shuffle_mode: str = "isolated" + + benchmark_id: Optional[str] = None + + enable_tool_shortlisting: bool = False + max_selected_tools: int = 30 + + runner: RunnerName | None = None + model_settings: ModelSettings | None = None + + @classmethod + def _get_instance_class(cls): + from .autoskill_instance import AutoSkillAgentInstance + return AutoSkillAgentInstance + + @classmethod + def _get_instance_class_ref(cls) -> str: + return "exgentic.agents.autoskill.autoskill_instance:AutoSkillAgentInstance" + + def _get_instance_kwargs(self, session_id: str) -> dict[str, Any]: + return { + "session_id": session_id, + "model": self.model, + "skill_model": self.skill_model or self.model, + "retrieve_k": self.retrieve_k, + "retrieval_threshold": self.retrieval_threshold, + "bm25_weight": self.bm25_weight, + "dedupe_similarity_threshold": self.dedupe_similarity_threshold, + "embedding_model": self.embedding_model, + "enable_query_rewrite": self.enable_query_rewrite, + "max_context_chars": self.max_context_chars, + "shuffle_mode": self.shuffle_mode, + "model_settings": self.model_settings, + "benchmark_id": self.benchmark_id, + "enable_tool_shortlisting": self.enable_tool_shortlisting, + "max_selected_tools": self.max_selected_tools, + } + + @property + def model_name(self) -> str: + return str(self.model).split("/")[-1] + + def get_models_names(self) -> list[str]: + names = [str(self.model)] + sm = self.skill_model or self.model + if sm != self.model: + names.append(str(sm)) + return names diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/autoskill/autoskill_instance.py b/labs/AgentStream/exgentic/src/exgentic/agents/autoskill/autoskill_instance.py new file mode 100644 index 00000000..b2d9bff2 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/autoskill/autoskill_instance.py @@ -0,0 +1,636 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +import json +import time +from datetime import datetime +from typing import Any, Dict, List, Optional, Union + +import litellm +from litellm import ( + ChatCompletionAssistantMessage, + ChatCompletionSystemMessage, + ChatCompletionToolMessage, + ChatCompletionUserMessage, +) + +from ...core.agent_instance import AgentInstance +from ...core.types import ( + Action, + ActionType, + Message, + MessageAction, + MessageObservation, + MessagePayload, + ModelSettings, + Observation, +) +from ...utils.cost import LiteLLMCostReport +from ...utils.settings import get_settings + +from .prompts import QUERY_REWRITE_PROMPT, SKILL_CONTEXT_TEMPLATE, SKILL_ENTRY_TEMPLATE +from .skill_extraction import extract_skills_from_trace +from .skill_maintenance import maintain_skill +from .skill_retrieval import compute_embedding, hybrid_search +from .skill_store import SkillStore + +try: + from ...agents.litellm_tool_calling.utils import ToolCall, ToolsActionsRegistry +except ImportError: + ToolsActionsRegistry = None + ToolCall = dict + +from ..tool_shortlisting import shortlist_tools + +settings = get_settings() + + +class AutoSkillAgentInstance(AgentInstance): + + def __init__( + self, + session_id: str, + model: str = "gpt-4o", + skill_model: str = "gpt-4o", + retrieve_k: int = 5, + retrieval_threshold: float = 0.3, + bm25_weight: float = 0.1, + dedupe_similarity_threshold: float = 0.4, + embedding_model: str = "text-embedding-3-small", + enable_query_rewrite: bool = True, + max_context_chars: int = 6000, + shuffle_mode: str = "isolated", + model_settings: Optional[ModelSettings] = None, + benchmark_id: Optional[str] = None, + enable_tool_shortlisting: bool = False, + max_selected_tools: int = 30, + ) -> None: + super().__init__(session_id) + + self.model = model + self.skill_model = skill_model + self.retrieve_k = retrieve_k + self.retrieval_threshold = retrieval_threshold + self.bm25_weight = bm25_weight + self.dedupe_similarity_threshold = dedupe_similarity_threshold + self.embedding_model = embedding_model + self.enable_query_rewrite = enable_query_rewrite + self.max_context_chars = max_context_chars + self.shuffle_mode = shuffle_mode + self.benchmark_id = benchmark_id + self.enable_tool_shortlisting = enable_tool_shortlisting + self.max_selected_tools = max_selected_tools + + if model_settings is None: + self._model_settings = ModelSettings() + elif isinstance(model_settings, ModelSettings): + self._model_settings = model_settings + else: + self._model_settings = ModelSettings() + + self._cost = LiteLLMCostReport.initialize_empty(model_name=self.model) + self._store: Optional[SkillStore] = None + + self.messages: list[ + Union[ + ChatCompletionAssistantMessage, + ChatCompletionToolMessage, + ChatCompletionUserMessage, + ChatCompletionSystemMessage, + ] + ] = [] + self._step_count: int = 0 + + self._registry: Optional[ToolsActionsRegistry] = None + self._all_actions: list[ActionType] = [] + + self._observation_log: List[Dict[str, Any]] = [] + self._action_log: List[Dict[str, Any]] = [] + + def _log_failure( + self, component: str, error: Exception, context: Dict[str, Any] + ) -> None: + try: + log_path = self.paths.agent_dir / "autoskill_failures.jsonl" + log_path.parent.mkdir(parents=True, exist_ok=True) + entry = { + "timestamp": datetime.now().isoformat(), + "session_id": self.session_id, + "component": component, + "error_type": type(error).__name__, + "error_message": str(error)[:2000], + **{k: str(v)[:2000] if isinstance(v, str) else v + for k, v in context.items()}, + } + with open(log_path, "a", encoding="utf-8") as fh: + fh.write(json.dumps(entry, ensure_ascii=False) + "\n") + except Exception: + pass + + def start( + self, + task: str, + context: Dict[str, Any], + actions: list[ActionType], + ) -> None: + super().start(task, context, actions) + + self._all_actions = list(self.actions) + if ToolsActionsRegistry is not None: + self._registry = ToolsActionsRegistry(self._all_actions) + + task_group = str( + context.get("task_group") + or context.get("task_id") + or context.get("task_name") + or "default" + ) + self._store = SkillStore.get_or_create( + shuffle_mode=self.shuffle_mode, + task_group=task_group, + benchmark_id=self.benchmark_id, + ) + self._store.increment_session() + + skill_context = self._retrieve_skills(task, context) + + system_content = self._build_system_prompt(skill_context) + self._add_message( + ChatCompletionSystemMessage(role="system", content=system_content) + ) + + content_parts: list[Any] = [] + ctx = "" + if self.context: + for k, v in self.context.items(): + if isinstance(v, dict) and v.get("type") == "image_url": + content_parts.append({"type": "image_url", "image_url": {"url": v["data"], "detail": "high"}}) + else: + ctx += f"\n<{k}>\n{v}\n" + + text_content = f"{self.task}\n{ctx}" + if content_parts: + content_parts.insert(0, {"type": "text", "text": text_content}) + self._add_message(ChatCompletionUserMessage(role="user", content=content_parts)) + else: + self._add_message( + ChatCompletionUserMessage(role="user", content=text_content) + ) + + self.logger.info( + "AutoSkill instance started store=%s session_count=%d " + "skill_count=%d benchmark=%s tools=%d", + self._store.store_id, + self._store.session_count, + self._store.skill_count, + self.benchmark_id or "(none)", + len(self._all_actions), + ) + + def react(self, observation: Optional[Observation]) -> Optional[Action]: + self._step_count += 1 + + self._observe(observation) + self._log_observation(observation) + + tools = self._assistant_tools() + response = self._completion( + model=self.model, + messages=self.messages, + tools=tools if tools else None, + ) + + if response is None: + self.logger.error("AutoSkill: LLM returned None response") + return None + + if response.usage: + self._cost.update_cost_from_tokens( + response.usage.prompt_tokens, + response.usage.completion_tokens, + ) + + choice = response["choices"][0] + message = choice["message"] + finish_reason = choice.get("finish_reason") + + if finish_reason == "tool_calls" and self._registry is not None: + tool_calls = self._extract_tool_calls(message) + self._add_message( + ChatCompletionAssistantMessage( + role="assistant", + tool_calls=[ + { + "id": tc["id"], + "type": "function", + "function": { + "name": tc["name"], + "arguments": tc["arguments"], + }, + } + for tc in tool_calls + ], + ) + ) + actions = self._registry.tool_calls_to_action(tool_calls) + + for tc in tool_calls: + self._action_log.append({ + "step": self._step_count, + "action": tc["name"], + "arguments": tc["arguments"], + }) + + self.logger.info("AutoSkill step %d: tool_calls=%s", self._step_count, + [tc["name"] for tc in tool_calls]) + return actions + else: + content = message.content if message.content else "" + self._add_message( + ChatCompletionAssistantMessage( + role="assistant", content=content + ) + ) + self._action_log.append({ + "step": self._step_count, + "action": "message", + "content": content, + }) + self.logger.info("AutoSkill step %d: message response", self._step_count) + return MessageAction(arguments=Message(content=content)) + + def close(self) -> None: + store = self._store + if store is None: + return + + action_taken = "no_extraction" + skill_name = "" + + if self._observation_log or self._action_log: + try: + session_trace = self._build_session_trace() + candidates = extract_skills_from_trace( + task=self.task if self.task else "", + benchmark_id=self.benchmark_id or "", + session_trace=session_trace, + llm_call=self._llm_call_simple, + model=self.skill_model, + logger=self.logger, + ) + + if candidates: + candidate = candidates[0] + action_taken, _ = maintain_skill( + candidate=candidate, + store=store, + llm_call=self._llm_call_simple, + model=self.skill_model, + embedding_model=self.embedding_model, + bm25_weight=self.bm25_weight, + dedupe_similarity_threshold=self.dedupe_similarity_threshold, + logger=self.logger, + ) + skill_name = candidate.name + self.logger.info( + "AutoSkill close: action=%s skill=%s", + action_taken, skill_name, + ) + + except Exception as exc: + self.logger.warning("AutoSkill: skill extraction/maintenance failed: %s", exc) + self._log_failure("skill_evolution", exc, { + "observation_count": len(self._observation_log), + "action_count": len(self._action_log), + }) + + store.record_learning( + session_id=self.session_id, + task_id=str(self.context.get("task_id", "") if self.context else ""), + benchmark_id=self.benchmark_id or "", + action=action_taken, + skill_name=skill_name, + ) + + try: + cp = str(self.paths.agent_dir / "skillstore_checkpoint.json") + store.save_checkpoint(cp) + txt = str(self.paths.agent_dir / "skillbank.txt") + store.save_skills_text(txt) + except Exception as exc: + self.logger.warning("AutoSkill: failed to save checkpoint: %s", exc) + + def get_cost(self) -> LiteLLMCostReport: + return self._cost + + def _retrieve_skills(self, task: str, context: Dict[str, Any]) -> str: + store = self._store + if store is None or store.skill_count == 0: + return "" + + query = task + if self.enable_query_rewrite and task: + try: + ctx_parts = [] + for _, v in context.items(): + if isinstance(v, str): + ctx_parts.append(v) + ctx_str = " ".join(ctx_parts) + rewritten = self._llm_call_simple( + self.skill_model, + QUERY_REWRITE_PROMPT.format(task=task, context=ctx_str), + ) + if rewritten and len(rewritten.strip()) > 5: + query = rewritten.strip() + self.logger.info("AutoSkill retrieval: query rewritten to '%s'", query[:100]) + except Exception as exc: + self.logger.debug("AutoSkill retrieval: query rewrite failed: %s", exc) + + query_embedding = None + try: + query_embedding = compute_embedding(query, model=self.embedding_model) + except Exception as exc: + self.logger.warning("AutoSkill retrieval: failed to compute query embedding: %s", exc) + + results = hybrid_search( + store=store, + query=query, + query_embedding=query_embedding, + top_k=self.retrieve_k, + threshold=self.retrieval_threshold, + bm25_weight=self.bm25_weight, + embedding_model=self.embedding_model, + ) + + if not results: + self.logger.info("AutoSkill retrieval: no skills above threshold %.2f", self.retrieval_threshold) + return "" + + self.logger.info( + "AutoSkill retrieval: %d skills retrieved (top score=%.3f)", + len(results), results[0][1], + ) + + skills_block = "" + char_budget = self.max_context_chars + for skill, _ in results: + entry_text = SKILL_ENTRY_TEMPLATE.format( + name=skill.name, + description=skill.description, + tags=", ".join(skill.tags), + triggers=", ".join(skill.triggers), + instructions=skill.instructions, + ) + if len(skills_block) + len(entry_text) > char_budget: + break + skills_block += entry_text + "\n" + + return SKILL_CONTEXT_TEMPLATE.format(skills_block=skills_block) + + def _build_system_prompt(self, skill_context: str) -> str: + parts = [ + "You are an expert agent that completes tasks using available tools.", + "Think step-by-step before acting.", + "Use available tools to interact with the environment.", + "When you are confident in your solution, use the finish/submit tool.", + ] + + if skill_context: + parts.extend(["", skill_context]) + else: + parts.extend([ + "", + "## Skills", + "(No accumulated skills yet. This is an early session.)", + ]) + + return "\n".join(parts) + + def _add_message(self, message: Any) -> None: + self.messages.append(message) + + def _observe(self, observation: Optional[Observation]) -> None: + if observation is None: + return + + observations = observation.to_observation_list() + if observation.is_empty(): + if not any(obs.invoking_actions for obs in observations): + return + + for obs in observations: + if isinstance(obs, MessageObservation) and isinstance( + obs.result, MessagePayload + ): + self._add_message( + ChatCompletionUserMessage( + role="user", content=obs.result.message + ) + ) + continue + + if len(obs.invoking_actions) > 0: + invoking = obs.invoking_actions[0] + if invoking.name == "message": + self._add_message( + ChatCompletionUserMessage( + role="user", content=str(obs) + ) + ) + continue + + action_id = invoking.id + tool_call_id = invoking.id + if not ( + isinstance(tool_call_id, str) + and tool_call_id.startswith("call_") + ): + if self._registry is not None: + tool_call_id = ( + self._registry.action_id_to_tool_call_id.get( + action_id, tool_call_id + ) + ) + + value = obs.result + try: + content = json.dumps( + value, ensure_ascii=False, separators=(",", ":") + ) + except TypeError: + content = str(value) + + if tool_call_id is not None: + self._add_message( + ChatCompletionToolMessage( + role="tool", + tool_call_id=tool_call_id, + content=content, + ) + ) + else: + self._add_message( + ChatCompletionUserMessage( + role="user", + content=f"Tool result: {content}", + ) + ) + else: + self._add_message( + ChatCompletionUserMessage( + role="user", content=str(obs) + ) + ) + + def _log_observation(self, observation: Optional[Observation]) -> None: + if observation is None or observation.is_empty(): + return + + for obs in observation.to_observation_list(): + result = obs.result + if result is None: + continue + + entry: Dict[str, Any] = {"step": self._step_count} + if isinstance(result, str): + entry["content"] = result + elif isinstance(result, dict): + entry["content"] = json.dumps(result, ensure_ascii=False) + else: + entry["content"] = str(result) + + if obs.invoking_actions: + entry["action"] = obs.invoking_actions[0].name + + self._observation_log.append(entry) + + def _assistant_tools(self) -> list[dict[str, Any]]: + if self._registry is None: + return [] + tools = self._registry.openai_tools() + if not self.enable_tool_shortlisting: + return tools + + def _cost_cb(usage): + if usage: + self._cost.update_cost_from_tokens( + usage.prompt_tokens, usage.completion_tokens + ) + + return shortlist_tools( + tools=tools, + max_selected=self.max_selected_tools, + messages=self.messages, + completion_fn=self._completion, + model=self.model, + logger=self.logger, + cost_callback=_cost_cb, + ) + + @staticmethod + def _extract_tool_calls(message: Any) -> list[dict[str, str]]: + if not hasattr(message, "tool_calls") or not message.tool_calls: + return [] + tool_calls = [] + for tc in message.tool_calls: + tool_calls.append({ + "name": tc.function.name, + "arguments": tc.function.arguments, + "id": tc.id, + }) + return tool_calls + + def _completion(self, **kwargs) -> Any: + call_kwargs = self._model_settings.model_dump( + exclude_none=True, + exclude={"num_retries", "retry_after", "retry_strategy"}, + ) + call_kwargs.update(kwargs) + if call_kwargs.get("tools") is None: + call_kwargs.pop("tools", None) + + max_attempts = 3 + for attempt in range(max_attempts): + try: + response = litellm.completion(**call_kwargs) + return response + except Exception as exc: + self.logger.warning( + "AutoSkill LLM call attempt %d/%d failed: %s", + attempt + 1, max_attempts, exc, + ) + if attempt + 1 >= max_attempts: + raise + time.sleep(2 ** attempt) + return None + + def _llm_call_simple( + self, + model: str, + prompt: str, + *, + json_mode: bool = False, + ) -> str: + kwargs: Dict[str, Any] = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.0, + } + if json_mode: + kwargs["response_format"] = {"type": "json_object"} + + max_attempts = 3 + for attempt in range(max_attempts): + try: + resp = litellm.completion(**kwargs) + if resp.usage: + self._cost.update_cost_from_tokens( + resp.usage.prompt_tokens, + resp.usage.completion_tokens, + ) + content = resp.choices[0].message.content + if content is None: + raise ValueError("LLM returned None content") + return content + except Exception as exc: + self.logger.warning( + "AutoSkill simple LLM call attempt %d/%d failed: %s", + attempt + 1, max_attempts, exc, + ) + if attempt + 1 >= max_attempts: + self._log_failure("llm_call", exc, { + "model": model, + "prompt_length": len(prompt), + "attempts": max_attempts, + }) + raise + time.sleep(2 ** attempt) + return "" + + def _build_session_trace(self) -> str: + events: List[Dict[str, Any]] = [] + for entry in self._action_log: + events.append({"type": "action", **entry}) + for entry in self._observation_log: + events.append({"type": "observation", **entry}) + + events.sort(key=lambda e: (e.get("step", 0), 0 if e["type"] == "action" else 1)) + + lines: List[str] = [] + for event in events: + step = event.get("step", "?") + if event["type"] == "action": + action = event.get("action", "?") + args = event.get("arguments", event.get("content", "")) + lines.append(f"[Step {step}] Action: {action}") + if args: + lines.append(f" Args: {str(args)}") + else: + action = event.get("action", "env") + content = event.get("content", "") + lines.append(f"[Step {step}] Observation from {action}:") + lines.append(f" {content}") + + return "\n".join(lines) if lines else "(No session trace recorded)" diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/autoskill/prompts.py b/labs/AgentStream/exgentic/src/exgentic/agents/autoskill/prompts.py new file mode 100644 index 00000000..eebe79fd --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/autoskill/prompts.py @@ -0,0 +1,153 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +QUERY_REWRITE_PROMPT = """\ +You are a retrieval query rewriter. Your job is to rewrite the current user task \ +into a concise, standalone search query for skill retrieval. + +Core rules: +- Produce exactly ONE line of output: the rewritten query. +- Resolve references ("it", "this", "the above") using the provided context. +- Keep only retrieval-relevant constraints (format, audience, quality, domain). +- Preserve the task anchor (what the task is about). +- Do NOT include generic process words without a concrete topic anchor. + +Task: {task} +Context: {context} + +Rewritten query:""" + + +SKILL_EXTRACTION_PROMPT = """\ +You are a skill extractor that turns agent interaction traces into reusable skills. + +## Extraction Principles +- Treat the task description and environment observations as primary evidence. +- Extract ONLY when there are durable, reusable constraints, policies, workflows, \ +or strategies that would help in FUTURE similar tasks. +- Do NOT extract one-shot task-specific facts or generic "be helpful" patterns. +- Capture HOW TO DO similar tasks, rather than this-instance facts. +- Remove case-specific entities (names, URLs, dates) and preserve only portable rules. +- Do NOT invent workflow steps unless explicitly demonstrated in the trace. +- If nothing reusable is found, return an empty skills list. + +## Session Information +Task: {task} + +## Session Trace (Actions & Observations) +{session_trace} + +## Output Format +Return a JSON object with this schema: +{{ + "skills": [ + {{ + "name": "", + "description": "", + "instructions": "", + "triggers": ["", "", ...], + "tags": ["", "", ...], + "confidence": + }} + ] +}} + +If nothing reusable is detected, return: {{"skills": []}} +""" + +SKILL_JUDGE_PROMPT = """\ +You are a skill set manager. Given a newly extracted skill candidate and the most \ +similar existing skill from the skill bank, decide the appropriate action. + +## Decision Procedure +1. Check if the candidate represents the same capability as the existing skill \ +(same job-to-be-done, same deliverable type, overlapping constraints). +2. Apply discard gate: reject generic, low-signal, non-portable candidates. +3. Compare on four axes: job-to-be-done, deliverable type, hard constraints/success \ +criteria, and required tools/workflow. +4. Choose "merge" ONLY when they are the same capability after removing instance details. +5. Choose "add" when the candidate is a distinct durable capability. +6. Choose "discard" when the candidate is too generic or non-reusable. + +## Candidate Skill +Name: {candidate_name} +Description: {candidate_description} +Instructions: {candidate_instructions} +Triggers: {candidate_triggers} +Tags: {candidate_tags} + +## Most Similar Existing Skill (may be empty if no skills exist) +Name: {existing_name} +Description: {existing_description} +Instructions: {existing_instructions} +Triggers: {existing_triggers} +Tags: {existing_tags} +Similarity Score: {similarity_score} + +## Output Format +Return a JSON object: +{{ + "action": "add" | "merge" | "discard", + "target_skill_id": "", + "reason": "" +}} +""" + +SKILL_MERGE_PROMPT = """\ +You are a skill merger. Combine an existing skill with a new candidate into one \ +improved skill that preserves the best of both. + +## Merge Rules +- Preserve the original capability identity (name and core goal). +- Perform semantic union rather than raw concatenation. +- Import only reusable, non-conflicting additions from the candidate. +- Avoid regressions: keep important checks from the existing skill. +- Remove case-specific entities and one-off facts. +- Do NOT invent any new standards or details not present in either skill. +- Deduplicate sections, bullets, triggers, tags. +- Keep language consistent across all fields. + +## Existing Skill +Name: {existing_name} +Description: {existing_description} +Instructions: {existing_instructions} +Triggers: {existing_triggers} +Tags: {existing_tags} + +## Candidate Skill (new evidence) +Name: {candidate_name} +Description: {candidate_description} +Instructions: {candidate_instructions} +Triggers: {candidate_triggers} +Tags: {candidate_tags} + +## Output Format +Return a JSON object with the merged skill: +{{ + "name": "", + "description": "", + "instructions": "", + "triggers": ["", ...], + "tags": ["", ...] +}} +""" + +SKILL_CONTEXT_TEMPLATE = """\ +## Retrieved Skills (from accumulated experience) +The following skills were retrieved based on relevance to the current task. \ +Use a skill ONLY when it directly matches the current intent. \ +Otherwise, ignore all retrieved skills and act normally. \ +Never explicitly mention that skills were retrieved/injected. + +{skills_block} +""" + +SKILL_ENTRY_TEMPLATE = """\ +### Skill: {name} +- **Description**: {description} +- **Tags**: {tags} +- **Triggers**: {triggers} + +**Instructions**: +{instructions} +""" diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/autoskill/skill_extraction.py b/labs/AgentStream/exgentic/src/exgentic/agents/autoskill/skill_extraction.py new file mode 100644 index 00000000..4478b4b4 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/autoskill/skill_extraction.py @@ -0,0 +1,141 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +import json +import re +import uuid +from typing import Any, Callable, Dict, List, Optional + +from .prompts import SKILL_EXTRACTION_PROMPT +from .skill_store import SkillEntry + + +def extract_json_from_text(text: str) -> Optional[Dict[str, Any]]: + text = text.strip() + try: + return json.loads(text) + except (json.JSONDecodeError, ValueError): + pass + + match = re.search(r"```(?:json)?\s*\n?(.*?)\n?```", text, re.DOTALL) + if match: + try: + return json.loads(match.group(1).strip()) + except (json.JSONDecodeError, ValueError): + pass + + start = text.find("{") + if start >= 0: + depth = 0 + for i in range(start, len(text)): + if text[i] == "{": + depth += 1 + elif text[i] == "}": + depth -= 1 + if depth == 0: + try: + return json.loads(text[start:i + 1]) + except (json.JSONDecodeError, ValueError): + break + return None + + +def _repair_json_via_llm( + raw_text: str, + llm_call: Callable[[str, str], str], + model: str, +) -> Optional[Dict[str, Any]]: + repair_prompt = ( + "The following text was supposed to be valid JSON matching the schema " + '{"skills": [{"name": str, "description": str, "instructions": str, ' + '"triggers": [str], "tags": [str], "confidence": float}]} ' + "but it is malformed. Please fix it and return ONLY valid JSON. " + 'If extraction fails, output {"skills": []}.\n\n' + f"Malformed text:\n{raw_text[:4000]}" + ) + try: + repaired = llm_call(model, repair_prompt) + if repaired: + return extract_json_from_text(repaired) + except Exception: + pass + return None + + +def extract_skills_from_trace( + task: str, + benchmark_id: str, + session_trace: str, + llm_call: Callable[[str, str], str], + model: str, + logger: Optional[Any] = None, +) -> List[SkillEntry]: + import logging + _logger = logger or logging.getLogger(__name__) + + prompt = SKILL_EXTRACTION_PROMPT.format( + task=task, + session_trace=session_trace, + ) + + _logger.info( + "AutoSkill extraction: task='%s', trace_len=%d chars", + task[:80], len(session_trace), + ) + + raw = llm_call(model, prompt) + if not raw: + _logger.info("AutoSkill extraction: LLM returned empty response") + return [] + + _logger.debug("AutoSkill extraction: raw LLM response length=%d", len(raw)) + + parsed = extract_json_from_text(raw) + if parsed is None: + _logger.info("AutoSkill extraction: levels 1-3 JSON parse failed, attempting LLM repair") + parsed = _repair_json_via_llm(raw, llm_call, model) + if not parsed or not isinstance(parsed, dict): + _logger.warning("AutoSkill extraction: all 4 JSON recovery levels failed") + return [] + + skills_data = parsed.get("skills", []) + if not isinstance(skills_data, list): + _logger.warning("AutoSkill extraction: 'skills' field is not a list") + return [] + + _logger.info("AutoSkill extraction: LLM returned %d skill candidates", len(skills_data)) + + results: List[SkillEntry] = [] + for item in skills_data: + if not isinstance(item, dict): + continue + + name = item.get("name", "").strip() + description = item.get("description", "").strip() + instructions = item.get("instructions", "").strip() + confidence = float(item.get("confidence", 0.6)) + + if not name or not description: + _logger.debug("AutoSkill extraction: skipping candidate with empty name/description") + continue + if not instructions: + instructions = description + + entry = SkillEntry( + id=str(uuid.uuid4()), + name=name, + description=description, + instructions=instructions, + triggers=item.get("triggers", [])[:8], + tags=item.get("tags", [])[:8], + confidence=confidence, + ) + _logger.info( + "AutoSkill extraction: extracted skill '%s' (confidence=%.2f)", + name, confidence, + ) + results.append(entry) + + return results diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/autoskill/skill_maintenance.py b/labs/AgentStream/exgentic/src/exgentic/agents/autoskill/skill_maintenance.py new file mode 100644 index 00000000..fdc1ecd1 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/autoskill/skill_maintenance.py @@ -0,0 +1,207 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +import json +from typing import Any, Callable, Optional, Tuple + +from .prompts import SKILL_JUDGE_PROMPT, SKILL_MERGE_PROMPT +from .skill_extraction import extract_json_from_text +from .skill_retrieval import hybrid_search, compute_embedding +from .skill_store import SkillEntry, SkillStore + + +def judge_skill( + candidate: SkillEntry, + existing: Optional[SkillEntry], + similarity_score: float, + llm_call: Callable[[str, str], str], + model: str, +) -> Tuple[str, Optional[str], str]: + prompt = SKILL_JUDGE_PROMPT.format( + candidate_name=candidate.name, + candidate_description=candidate.description, + candidate_instructions=candidate.instructions[:2000], + candidate_triggers=", ".join(candidate.triggers), + candidate_tags=", ".join(candidate.tags), + existing_name=existing.name if existing else "(none - no existing skill)", + existing_description=existing.description if existing else "", + existing_instructions=(existing.instructions[:2000] if existing else ""), + existing_triggers=", ".join(existing.triggers) if existing else "", + existing_tags=", ".join(existing.tags) if existing else "", + similarity_score=f"{similarity_score:.3f}", + ) + + raw = llm_call(model, prompt) + parsed = extract_json_from_text(raw) + + if parsed and isinstance(parsed, dict): + action = parsed.get("action", "discard").lower().strip() + target_id = parsed.get("target_skill_id") + reason = parsed.get("reason", "") + if action in ("add", "merge", "discard"): + if action == "merge" and existing: + return action, existing.id, reason + elif action == "merge" and not existing: + return "add", None, reason + " (no target for merge, adding instead)" + return action, target_id, reason + + if existing and similarity_score >= 0.82: + return "merge", existing.id, "high similarity (deterministic fallback)" + elif not existing or similarity_score <= 0.22: + return "add", None, "low similarity, distinct skill (deterministic fallback)" + elif similarity_score >= 0.50: + return "merge", existing.id, "moderate similarity (deterministic fallback)" + else: + return "add", None, "below merge threshold (deterministic fallback)" + + +def merge_skills( + existing: SkillEntry, + candidate: SkillEntry, + llm_call: Callable[[str, str], str], + model: str, +) -> SkillEntry: + prompt = SKILL_MERGE_PROMPT.format( + existing_name=existing.name, + existing_description=existing.description, + existing_instructions=existing.instructions[:3000], + existing_triggers=json.dumps(existing.triggers, ensure_ascii=False), + existing_tags=json.dumps(existing.tags, ensure_ascii=False), + candidate_name=candidate.name, + candidate_description=candidate.description, + candidate_instructions=candidate.instructions[:3000], + candidate_triggers=json.dumps(candidate.triggers, ensure_ascii=False), + candidate_tags=json.dumps(candidate.tags, ensure_ascii=False), + ) + + raw = llm_call(model, prompt) + parsed = extract_json_from_text(raw) + + merged = SkillEntry( + id=existing.id, + name=existing.name, + description=existing.description, + instructions=existing.instructions, + triggers=list(existing.triggers), + tags=list(existing.tags), + examples=list(existing.examples), + version=existing.version, + confidence=max(existing.confidence, candidate.confidence), + created_at=existing.created_at, + updated_at=existing.updated_at, + ) + + if parsed and isinstance(parsed, dict): + if parsed.get("name"): + merged.name = parsed["name"] + if parsed.get("description"): + merged.description = parsed["description"] + if parsed.get("instructions"): + merged.instructions = parsed["instructions"] + if parsed.get("triggers"): + merged.triggers = list(set(existing.triggers + parsed["triggers"]))[:10] + if parsed.get("tags"): + merged.tags = list(set(existing.tags + parsed["tags"]))[:10] + else: + merged.triggers = list(set(existing.triggers + candidate.triggers))[:10] + merged.tags = list(set(existing.tags + candidate.tags))[:10] + if candidate.instructions and candidate.instructions not in existing.instructions: + merged.instructions = ( + existing.instructions + "\n\n## Updated Constraints\n" + candidate.instructions + ) + + merged.bump_version() + return merged + + +def maintain_skill( + candidate: SkillEntry, + store: SkillStore, + llm_call: Callable[[str, str], str], + model: str, + embedding_model: str = "text-embedding-3-small", + bm25_weight: float = 0.1, + dedupe_similarity_threshold: float = 0.4, + logger: Any = None, +) -> Tuple[str, Optional[SkillEntry]]: + import logging + _logger = logger or logging.getLogger(__name__) + + # Compute candidate embedding for retrieval + candidate_text = candidate.to_search_text() + candidate_embedding = None + try: + candidate_embedding = compute_embedding(candidate_text, model=embedding_model) + except Exception as exc: + _logger.warning( + "AutoSkill maintenance: failed to compute candidate embedding for '%s': %s", + candidate.name, exc, + ) + _logger.info( + "AutoSkill maintenance: candidate='%s', embedding_dim=%d", + candidate.name, len(candidate_embedding) if candidate_embedding else 0, + ) + + results = hybrid_search( + store=store, + query=candidate_text, + query_embedding=candidate_embedding, + top_k=1, + threshold=0.0, + bm25_weight=bm25_weight, + embedding_model=embedding_model, + ) + + existing: Optional[SkillEntry] = None + similarity_score = 0.0 + if results: + existing, similarity_score = results[0] + _logger.info( + "AutoSkill maintenance: best match='%s' (score=%.3f)", + existing.name, similarity_score, + ) + else: + _logger.info("AutoSkill maintenance: no existing skills in store") + + if existing and similarity_score < dedupe_similarity_threshold: + _logger.info( + "AutoSkill maintenance: similarity %.3f < threshold %.3f, skipping merge consideration", + similarity_score, dedupe_similarity_threshold, + ) + existing = None + similarity_score = 0.0 + + action, _, reason = judge_skill( + candidate, existing, similarity_score, llm_call, model, + ) + _logger.info( + "AutoSkill maintenance: judge decision='%s', reason='%s'", + action, reason[:100], + ) + + if action == "discard": + _logger.info("AutoSkill maintenance: discarded candidate '%s'", candidate.name) + return "discard", None + elif action == "merge" and existing: + merged = merge_skills(existing, candidate, llm_call, model) + store.update_skill(merged) + if candidate_embedding: + new_emb = compute_embedding(merged.to_search_text(), model=embedding_model) + if new_emb: + store.set_embedding(merged.id, new_emb) + _logger.info( + "AutoSkill maintenance: merged into '%s' (v%s → v%s)", + merged.name, existing.version, merged.version, + ) + return "merge", merged + else: + store.add_skill(candidate) + if candidate_embedding: + store.set_embedding(candidate.id, candidate_embedding) + _logger.info( + "AutoSkill maintenance: added new skill '%s' (id=%s)", + candidate.name, candidate.id, + ) + return "add", candidate diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/autoskill/skill_retrieval.py b/labs/AgentStream/exgentic/src/exgentic/agents/autoskill/skill_retrieval.py new file mode 100644 index 00000000..c4d24c41 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/autoskill/skill_retrieval.py @@ -0,0 +1,159 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +import logging +import math +import re +from collections import Counter +from typing import Dict, List, Optional, Tuple + +from .skill_store import SkillEntry, SkillStore + +logger = logging.getLogger(__name__) + +_TOKEN_RE = re.compile(r"[A-Za-z0-9_]+|[\u4e00-\u9fff]|[^\W\d_]+", re.UNICODE) +_STOPWORDS = frozenset([ + "the", "a", "an", "is", "are", "was", "were", "be", "been", + "being", "have", "has", "had", "do", "does", "did", "will", + "would", "could", "should", "may", "might", "shall", "can", + "to", "of", "in", "for", "on", "with", "at", "by", "from", + "and", "or", "but", "not", "if", "then", "else", "when", + "that", "this", "it", "its", "as", "so", "no", "yes", +]) + + +def tokenize(text: str) -> List[str]: + tokens = _TOKEN_RE.findall(text.lower()) + return [t for t in tokens if t not in _STOPWORDS and len(t) > 1] + + +_st_model = None +_st_model_name = None + + +def _get_st_model(model_name: str = "all-MiniLM-L6-v2"): + global _st_model, _st_model_name + if _st_model is None or _st_model_name != model_name: + from sentence_transformers import SentenceTransformer + logger.info("Loading SentenceTransformer model: %s", model_name) + _st_model = SentenceTransformer(model_name) + _st_model_name = model_name + return _st_model + + +def compute_embedding(text: str, model: str = "all-MiniLM-L6-v2") -> List[float]: + st = _get_st_model(model) + vec = st.encode([text])[0] + return vec.tolist() + + +def bm25_score( + query_tokens: List[str], + doc_tokens: List[str], + avg_doc_len: float, + doc_count: int, + df: Dict[str, int], + k1: float = 1.5, + b: float = 0.75, +) -> float: + if not query_tokens or not doc_tokens: + return 0.0 + + doc_len = len(doc_tokens) + doc_tf = Counter(doc_tokens) + score = 0.0 + + for term in query_tokens: + if term not in doc_tf: + continue + tf = doc_tf[term] + n = df.get(term, 0) + idf = math.log((doc_count - n + 0.5) / (n + 0.5) + 1.0) + tf_norm = (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * doc_len / max(avg_doc_len, 1))) + score += idf * tf_norm + + return score + + +def cosine_similarity(a: List[float], b: List[float]) -> float: + if not a or not b or len(a) != len(b): + return 0.0 + dot = sum(x * y for x, y in zip(a, b)) + norm_a = math.sqrt(sum(x * x for x in a)) + norm_b = math.sqrt(sum(x * x for x in b)) + if norm_a == 0 or norm_b == 0: + return 0.0 + return dot / (norm_a * norm_b) + + +def hybrid_search( + store: SkillStore, + query: str, + query_embedding: Optional[List[float]] = None, + top_k: int = 5, + threshold: float = 0.3, + bm25_weight: float = 0.1, + embedding_model: str = "text-embedding-3-small", +) -> List[Tuple[SkillEntry, float]]: + skills = store.list_skills() + if not skills: + return [] + + query_tokens = tokenize(query) + doc_tokens_map: Dict[str, List[str]] = {} + df: Dict[str, int] = Counter() + + for skill in skills: + tokens = tokenize(skill.to_search_text()) + doc_tokens_map[skill.id] = tokens + for t in set(tokens): + df[t] += 1 + + avg_doc_len = sum(len(t) for t in doc_tokens_map.values()) / max(len(skills), 1) + + bm25_scores: Dict[str, float] = {} + for skill in skills: + bm25_scores[skill.id] = bm25_score( + query_tokens, doc_tokens_map[skill.id], + avg_doc_len, len(skills), df, + ) + + vec_scores: Dict[str, float] = {} + if query_embedding: + embeddings = store.get_embeddings() + for skill in skills: + emb = embeddings.get(skill.id) + if emb: + vec_scores[skill.id] = cosine_similarity(query_embedding, emb) + else: + vec_scores[skill.id] = 0.0 + else: + bm25_weight = 1.0 + for skill in skills: + vec_scores[skill.id] = 0.0 + + bm25_max = max(bm25_scores.values()) if bm25_scores else 0.0 + norm_bm25: Dict[str, float] = {} + if bm25_max > 0: + norm_bm25 = {k: v / bm25_max for k, v in bm25_scores.items()} + else: + norm_bm25 = {k: 0.0 for k in bm25_scores} + + final_scores: Dict[str, float] = {} + for skill in skills: + sid = skill.id + final_scores[sid] = ( + (1 - bm25_weight) * vec_scores.get(sid, 0.0) + + bm25_weight * norm_bm25.get(sid, 0.0) + ) + + skill_map = {s.id: s for s in skills} + results = [ + (skill_map[sid], score) + for sid, score in sorted(final_scores.items(), key=lambda x: -x[1]) + if score >= threshold + ] + + return results[:top_k] diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/autoskill/skill_store.py b/labs/AgentStream/exgentic/src/exgentic/agents/autoskill/skill_store.py new file mode 100644 index 00000000..a274925e --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/autoskill/skill_store.py @@ -0,0 +1,208 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +import json +import threading +import uuid +from dataclasses import asdict, dataclass, field +from datetime import datetime +from typing import Any, Dict, List, Optional + + +@dataclass +class SkillEntry: + + id: str + name: str + description: str + instructions: str + triggers: List[str] = field(default_factory=list) + tags: List[str] = field(default_factory=list) + examples: List[Dict[str, Any]] = field(default_factory=list) + version: str = "0.1.0" + confidence: float = 0.5 + created_at: str = "" + updated_at: str = "" + + def to_search_text(self) -> str: + parts = [self.name, self.description] + parts.extend(self.triggers) + parts.extend(self.tags) + parts.append(self.instructions) + return " ".join(parts) + + def bump_version(self) -> None: + parts = self.version.split(".") + if len(parts) == 3: + parts[2] = str(int(parts[2]) + 1) + self.version = ".".join(parts) + else: + self.version = "0.1.1" + self.updated_at = datetime.now().isoformat() + + +@dataclass +class LearningEvent: + + session_id: str + task_id: str + benchmark_id: str + action: str + skill_name: str + timestamp: str = "" + + +class SkillStore: + + _instances: Dict[str, "SkillStore"] = {} + _class_lock = threading.Lock() + + def __init__(self, store_id: str) -> None: + self._store_id = store_id + self._lock = threading.Lock() + self._skills: Dict[str, SkillEntry] = {} + self._embeddings: Dict[str, List[float]] = {} + self._session_count: int = 0 + self._history: List[LearningEvent] = [] + + @property + def store_id(self) -> str: + return self._store_id + + @property + def session_count(self) -> int: + with self._lock: + return self._session_count + + @property + def skill_count(self) -> int: + with self._lock: + return len(self._skills) + + @classmethod + def get_or_create( + cls, + shuffle_mode: str = "isolated", + benchmark_id: Optional[str] = None, + task_group: Optional[str] = None, + ) -> "SkillStore": + if shuffle_mode == "isolated": + key = f"autoskill_isolated_{benchmark_id or task_group or 'default'}" + elif shuffle_mode == "sequential": + key = "autoskill_sequential_global" + elif shuffle_mode == "interleaved": + key = "autoskill_interleaved_global" + else: + key = f"autoskill_{shuffle_mode}" + + with cls._class_lock: + if key not in cls._instances: + cls._instances[key] = cls(store_id=key) + return cls._instances[key] + + @classmethod + def list_stores(cls) -> Dict[str, "SkillStore"]: + with cls._class_lock: + return dict(cls._instances) + + @classmethod + def reset_all(cls) -> None: + with cls._class_lock: + cls._instances.clear() + + def increment_session(self) -> int: + with self._lock: + self._session_count += 1 + return self._session_count + + def add_skill(self, skill: SkillEntry) -> None: + with self._lock: + if not skill.id: + skill.id = str(uuid.uuid4()) + if not skill.created_at: + skill.created_at = datetime.now().isoformat() + skill.updated_at = skill.created_at + self._skills[skill.id] = skill + + def update_skill(self, skill: SkillEntry) -> None: + with self._lock: + skill.updated_at = datetime.now().isoformat() + self._skills[skill.id] = skill + + def list_skills(self) -> List[SkillEntry]: + with self._lock: + return list(self._skills.values()) + + def set_embedding(self, skill_id: str, embedding: List[float]) -> None: + with self._lock: + self._embeddings[skill_id] = embedding + + def get_embeddings(self) -> Dict[str, List[float]]: + with self._lock: + return dict(self._embeddings) + + def record_learning( + self, + session_id: str, + task_id: str, + benchmark_id: str, + action: str, + skill_name: str = "", + ) -> None: + with self._lock: + self._history.append(LearningEvent( + session_id=session_id, + task_id=task_id, + benchmark_id=benchmark_id, + action=action, + skill_name=skill_name, + timestamp=datetime.now().isoformat(), + )) + + def save_checkpoint(self, path: str) -> None: + with self._lock: + data = { + "store_id": self._store_id, + "session_count": self._session_count, + "skills": {sid: asdict(s) for sid, s in self._skills.items()}, + "embeddings": self._embeddings, + "history": [asdict(e) for e in self._history], + } + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + def load_checkpoint(self, path: str) -> None: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + with self._lock: + self._session_count = data.get("session_count", 0) + self._skills = {} + for sid, sdata in data.get("skills", {}).items(): + self._skills[sid] = SkillEntry(**{ + k: v for k, v in sdata.items() + if k in SkillEntry.__dataclass_fields__ + }) + self._embeddings = data.get("embeddings", {}) + self._history = [ + LearningEvent(**{ + k: v for k, v in e.items() + if k in LearningEvent.__dataclass_fields__ + }) + for e in data.get("history", []) + ] + + def save_skills_text(self, path: str) -> None: + with self._lock: + skills = list(self._skills.values()) + lines = [f"# SkillBank: {self._store_id} ({len(skills)} skills)\n"] + for s in skills: + lines.append(f"## {s.name} (v{s.version})") + lines.append(f" {s.description}") + lines.append(f" Tags: {', '.join(s.tags)}") + lines.append(f" Triggers: {', '.join(s.triggers)}") + lines.append(f" Instructions: {s.instructions}") + lines.append("") + with open(path, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/cli/__init__.py b/labs/AgentStream/exgentic/src/exgentic/agents/cli/__init__.py new file mode 100644 index 00000000..1aaf12d0 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/cli/__init__.py @@ -0,0 +1,9 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +# CLI agent bundle namespace + +from .claude.agent import ClaudeCodeAgent, ClaudeCodeAgentInstance # noqa: F401 +from .codex.agent import CodexAgent, CodexAgentInstance # noqa: F401 +from .command_runner import ExecutionBackend # noqa: F401 +from .gemini.agent import GeminiAgent, GeminiAgentInstance # noqa: F401 diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/cli/base.py b/labs/AgentStream/exgentic/src/exgentic/agents/cli/base.py new file mode 100644 index 00000000..395d0ab8 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/cli/base.py @@ -0,0 +1,350 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import abc +import logging +import os +import tempfile +from pathlib import Path +from typing import Any + +from ...adapters.agents.mcp_agent import MCPAgentInstance +from ...core.agent import Agent +from ...core.context import context_env +from ...core.types import ModelSettings +from ...integrations.litellm import LitellmProxy +from ...integrations.litellm.health import check_model_accessible_sync +from ...integrations.litellm.trace_cost import load_trace_cost +from ...utils.cost import UpdatableCostReport +from .command_runner import ( + BaseCLIConfig, + CLIResult, + DockerRunner, + ExecutionBackend, + PodmanRunner, + ProcessRunner, +) + + +class BaseCLIWrapper(abc.ABC): + """Shared helpers for headless CLI wrappers. + + Subclasses implement build_env/build_command; run() handles the rest. + """ + + config_prefix = "cli_" + spawn_error_message = "Spawn failed" + + def __init__( + self, + env: dict[str, str] | None = None, + log_path: Path | None = None, + config_dir: Path | None = None, + logger: logging.Logger | None = None, + runner: ExecutionBackend = ExecutionBackend.AUTO, + ) -> None: + self.env = env or os.environ.copy() + self.config_dir = config_dir + self.log_path = log_path + self._last_run_context: dict[str, Any] = {} + self._logger = logger or logging.getLogger(self.__class__.__name__) + if runner == ExecutionBackend.AUTO: + from .command_runner import resolve_container_backend + + runner = resolve_container_backend() + if runner == ExecutionBackend.PROCESS: + self.runner = ProcessRunner(log_path=log_path, logger=self._logger) + elif runner == ExecutionBackend.PODMAN: + self.runner = PodmanRunner(log_path=log_path, logger=self._logger) + elif runner == ExecutionBackend.DOCKER: + self.runner = DockerRunner(log_path=log_path, logger=self._logger) + else: + raise ValueError(f"runner value: {runner} is not supported!") + + def run(self, *, prompt: str, config: BaseCLIConfig) -> CLIResult: + cfg_root = self._resolve_config_root(self.config_prefix) + self._last_run_context = { + "cfg_root": cfg_root, + "prompt": prompt, + "config": config, + } + env = self.build_env(cfg_root=cfg_root, prompt=prompt, config=config) + env.update(context_env()) + if config.env: + env = {**env, **config.env} + cmd = self.build_command(cfg_root=cfg_root, prompt=prompt, config=config) + return self.runner.run( + cmd=cmd, + env=env, + cfg_root=cfg_root, + config=config, + spawn_error_message=self.spawn_error_message, + ) + + def close(self) -> None: + self.runner.close() + + def _resolve_config_root(self, prefix: str) -> Path: + if self.config_dir is not None: + return Path(self.config_dir) + return Path(tempfile.mkdtemp(prefix=prefix)) + + def _log_warning(self, message: str) -> None: + """Best-effort logger wrapper to avoid attribute errors on exit.""" + try: + self._logger.warning(message) + except Exception: + logging.getLogger(__name__).warning(message) + + # Abstract hooks ------------------------------------------------- + + @abc.abstractmethod + def build_env(self, *, cfg_root: Path, prompt: str, config: Any) -> dict[str, str]: + ... + + @abc.abstractmethod + def build_command(self, *, cfg_root: Path, prompt: str, config: Any) -> list[str]: + ... + + +class ProxyBackedMCPAgentInstance(MCPAgentInstance, abc.ABC): + """Base class for MCP agents that launch a LiteLLM proxy and delegate to a CLI wrapper.""" + + def __init__( + self, + session_id: str, + model_id: str, + *, + max_steps: int = 150, + model_alias: str | None = None, + execution_backend: ExecutionBackend = ExecutionBackend.AUTO, + model_settings: ModelSettings | None = None, + ) -> None: + super().__init__(session_id) + self.model_id = model_id + self.max_steps = max_steps + self._proxy_log_dir = self.paths.agent_dir / "litellm_proxy" + self._trace_log_path = self._proxy_log_dir / "trace.jsonl" + self._proxy: LitellmProxy | None = None + self._cli: BaseCLIWrapper | None = None + self._model_alias = model_alias + self.execution_backend = execution_backend + if model_settings is None: + self.model_settings = ModelSettings() + elif isinstance(model_settings, ModelSettings): + self.model_settings = model_settings + else: + raise ValueError("model_settings must be a ModelSettings instance.") + + # Check model accessibility + check_model_accessible_sync(self.model_id, logger=self.logger) + + @property + @abc.abstractmethod + def cli_display_name(self) -> str: + ... + + @abc.abstractmethod + def _build_cli(self) -> BaseCLIWrapper: + ... + + @abc.abstractmethod + def _run_cli( + self, + cli: BaseCLIWrapper, + prompt: str, + mcp_host: str, + mcp_port: int, + proxy: LitellmProxy, + ) -> Any: + ... + + def close_mcp_agent(self) -> None: + self.logger.info("Closing CLI before MCP shutdown") + if self._cli is not None: + self._cli.close() + self._cli = None + self.logger.info("Closing LiteLLM proxy before MCP shutdown") + if self._proxy is not None: + self._proxy.close() + self._proxy = None + super().close_mcp_agent() + + def run_mcp_agent(self, mcp_host: str, mcp_port: int) -> Any: + prompt = self._build_prompt() + proxy_log = self._proxy_log_dir / "litellm_proxy.log" + alias_map = self._proxy_alias_map() + + # Log model parameters for validation + params_info = [] + if self.model_settings.temperature is not None: + params_info.append(f"temperature={self.model_settings.temperature}") + if self.model_settings.max_tokens is not None: + params_info.append(f"max_tokens={self.model_settings.max_tokens}") + if self.model_settings.top_p is not None: + params_info.append(f"top_p={self.model_settings.top_p}") + params_str = f" with {', '.join(params_info)}" if params_info else "" + + self.logger.info( + "Starting LiteLLM proxy for model %s%s (log: %s)", + self.model_id, + params_str, + proxy_log, + ) + proxy_log.parent.mkdir(parents=True, exist_ok=True) + proxy = LitellmProxy( + model=self.model_id, + log_path=str(proxy_log), + usage_log_path=str(self._trace_log_path), + model_alias_map=alias_map or None, + model_settings=self.model_settings, + ) + self._proxy = proxy + try: + proxy.start() + except Exception: + self.logger.exception("LiteLLM proxy failed to start") + raise + self.logger.info("LiteLLM proxy started at %s", proxy.base_url) + + try: + cli = self._build_cli() + self._cli = cli + self.logger.info( + "Launching %s (log: %s) against MCP http://%s:%s/mcp using proxy %s", + self.cli_display_name, + cli.log_path, + mcp_host, + mcp_port, + proxy.base_url, + ) + stdout = self._run_cli(cli, prompt, mcp_host, mcp_port, proxy) + self.logger.info("%s run finished", self.cli_display_name) + return stdout + except Exception as e: + from .command_runner import CLIExecutionError + + if isinstance(e, CLIExecutionError): + if e.stderr: + self.logger.error("%s STDERR:\n%s", self.cli_display_name, e.stderr.rstrip()) + if e.stdout: + self.logger.error("%s STDOUT:\n%s", self.cli_display_name, e.stdout.rstrip()) + self.logger.exception("%s run failed: %s", self.cli_display_name, e) + raise + finally: + if self._cli is not None: + self._cli.close() + self._cli = None + proxy.close() + self._proxy = None + self._drain_server() + + def get_cost(self) -> UpdatableCostReport: + cost = load_trace_cost(self._trace_log_path, self.model_id) + report = UpdatableCostReport.initialize_empty(model_name=self.model_id) + report.add_cost(cost) + return report + + def _build_prompt(self) -> str: + prompt = "" + if self.context: + prompt += f"Context: {self.context}\n\n" + + finish_hint = "" + finish_tools = [a.name for a in self.actions if a.is_finish] + if finish_tools: + finish_hint = f" Use the designated finish tool(s): {', '.join(finish_tools)}." + + instructions = ( + "Complete this task using the available environment tools. Each tool corresponds to an action " + " you can take in the task environment.\n" + "# Important: You are on solo mode. Do not reply back or message unless its through a dedicated " + "environment tool call, every such attempt will finish the session with failure.\n" + "All your actions on with regard to the task must go through environment tool calls." + ) + + if finish_hint: + instructions += ( + f"{finish_hint} Always conclude by invoking the designated finish tool for this task environment." + ) + prompt += f"{instructions}\n" + + if self.initial_observation is not None and not self.initial_observation.is_empty(): + text = str(self.initial_observation).strip() + if text: + prompt += f"\nFirst Observation: {text}\n" + + return prompt + self.task + + def close(self) -> None: + if self._cli is not None: + self._cli.close() + self._cli = None + if self._proxy is not None: + self._proxy.close() + self._proxy = None + self._drain_server() + super().close() + + def _proxy_alias_map(self) -> dict[str, str]: + if not self._model_alias: + return {} + return {self._model_alias: self.model_id} + + def _drain_server(self) -> None: + """Best-effort stop/join of the MCP server thread to avoid teardown crashes.""" + server = self._mcp_server + if server is None: + return + try: + server.stop(raise_on_timeout=False) + except Exception as exc: + self._log_warning(f"Error while stopping MCP server: {exc}") + + def _log_warning(self, message: str) -> None: + try: + self._logger.warning(message) + except Exception: + logging.getLogger(__name__).warning(message) + + +class ProxyBackedAgent(Agent): + """Minimal agent factory helper for proxy-backed CLI agents.""" + + model: str + max_steps: int = 150 + + @classmethod + def _get_instance_class(cls): + raise NotImplementedError + + execution_backend: ExecutionBackend = ExecutionBackend.AUTO + model_settings: ModelSettings | None = None + + def _get_instance_kwargs( + self, + session_id: str, + ) -> dict[str, Any]: + # Resolve AUTO on the host side so the concrete backend (PODMAN/DOCKER) + # is serialized to the venv, where podman may not be on PATH. + backend = self.execution_backend + if backend == ExecutionBackend.AUTO: + from .command_runner import resolve_container_backend + + backend = resolve_container_backend() + return { + "session_id": session_id, + "model_id": self.model, + "max_steps": self.max_steps, + "execution_backend": backend, + "model_settings": self.model_settings, + } + + @property + def model_name(self) -> str: # type: ignore[override] + return str(self.model).split("/")[-1] + + def get_models_names(self) -> list[str]: # type: ignore[override] + return [str(self.model)] diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/cli/claude/__init__.py b/labs/AgentStream/exgentic/src/exgentic/agents/cli/claude/__init__.py new file mode 100644 index 00000000..95e9b24a --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/cli/claude/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from .agent import ClaudeCodeAgent, ClaudeCodeAgentInstance # noqa: F401 diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/cli/claude/agent.py b/labs/AgentStream/exgentic/src/exgentic/agents/cli/claude/agent.py new file mode 100644 index 00000000..220fb534 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/cli/claude/agent.py @@ -0,0 +1,93 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import os +from typing import Any, ClassVar + +from ....core.types import ModelSettings +from ..base import ExecutionBackend, ProxyBackedAgent, ProxyBackedMCPAgentInstance +from .cli import ClaudeCLIConfig, ClaudeCodeCLI + + +class ClaudeCodeAgentInstance(ProxyBackedMCPAgentInstance): + """Self-contained Claude Code CLI agent routed through a LiteLLM proxy.""" + + def __init__( + self, + session_id: str, + model_id: str, + max_steps: int = 150, + execution_backend: ExecutionBackend = ExecutionBackend.AUTO, + model_settings: ModelSettings | None = None, + ): + # The alias is what we ask Claude Code CLI for; the proxy maps it to the backend model. + # Must contain "sonnet-4" to get 64000 max_output_tokens in CLI + # (CLI hardcodes 8192 for names containing "3-5"). + self._claude_model_alias = "claude-sonnet-4-20250514" + super().__init__( + session_id, + model_id, + max_steps=max_steps, + model_alias=self._claude_model_alias, + execution_backend=execution_backend, + model_settings=model_settings, + ) + self._claude_log = self.paths.agent_dir / "claude_cli.log" + + @property + def cli_display_name(self) -> str: + return "Claude Code CLI" + + def _build_cli(self) -> ClaudeCodeCLI: + cfg_dir = self.paths.agent_dir / "claude_code_config" + return ClaudeCodeCLI( + env=os.environ.copy(), + log_path=self._claude_log, + config_dir=cfg_dir, + logger=self.logger, + runner=self.execution_backend, + ) + + def _run_cli( + self, + cli: ClaudeCodeCLI, + prompt: str, + mcp_host: str, + mcp_port: int, + proxy: Any, + ) -> Any: + # allowed_tools = [f"mcp__environment__{action.name}" for action in self.actions] + config = ClaudeCLIConfig( + mcp_host=mcp_host, + mcp_port=mcp_port, + provider_url=proxy.base_url, + backend_model=self.model_id, + claude_model=self._claude_model_alias, + # allowed_tools=allowed_tools, + max_turns=self.max_steps, + ) + config.env = { + "MCP_TIMEOUT": str(config.mcp_timeout_ms), + "MCP_TOOL_TIMEOUT": str(config.mcp_tool_timeout_ms), + "CLAUDE_CODE_MAX_OUTPUT_TOKENS": "32768", + } + result = cli.run(prompt=prompt, config=config) + return result.stdout + + def _stringify_empty_output(self) -> bool: + return True + + +class ClaudeCodeAgent(ProxyBackedAgent): + display_name: ClassVar[str] = "Claude Code CLI" + slug_name: ClassVar[str] = "claude_code" + execution_backend: ExecutionBackend = ExecutionBackend.AUTO + + @classmethod + def _get_instance_class(cls): + return ClaudeCodeAgentInstance + + def get_models_names(self) -> list[str]: # type: ignore[override] + return [str(self.model_id)] diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/cli/claude/cli.py b/labs/AgentStream/exgentic/src/exgentic/agents/cli/claude/cli.py new file mode 100644 index 00000000..225532f9 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/cli/claude/cli.py @@ -0,0 +1,148 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional + +from ..base import BaseCLIConfig, BaseCLIWrapper, ExecutionBackend + + +class ClaudeCLIConfig(BaseCLIConfig): + backend_model: str + claude_model: str + env_key: str = "ANTHROPIC_API_KEY" + auth_token_env: str = "ANTHROPIC_AUTH_TOKEN" + output_format: str = "text" + skip_permissions: bool = True + max_turns: int = 150 + mcp_only: bool = True # New: only allow MCP tools + allowed_tools: Optional[list[str]] = None + image: str = "exgentic-claude-code:dev" + image_workdir: str = "/work" + mcp_timeout_ms: int = 600_000 + mcp_tool_timeout_ms: int = 1_800_000 + + +class ClaudeCodeCLI(BaseCLIWrapper): + """Thin wrapper for running Claude Code's CLI in print mode.""" + + def __init__( + self, + env: Optional[dict[str, str]] = None, + log_path: Optional[Path] = None, + config_dir: Optional[Path] = None, + logger=None, + runner: ExecutionBackend = ExecutionBackend.PROCESS, + ) -> None: + super().__init__( + env=env, + log_path=log_path, + config_dir=config_dir, + logger=logger, + runner=runner, + ) + self._mcp_config_path: Optional[Path] = None + self.config_prefix = "claude_code_cli_" + self.spawn_error_message = "Failed to start Claude CLI" + + # Required hooks -------------------------------------------------- + + def build_env(self, *, cfg_root: Path, prompt: str, config: ClaudeCLIConfig) -> dict[str, str]: + env = self.env.copy() + env["ANTHROPIC_BASE_URL"] = config.provider_url + if config.env: + env.update(config.env) + + token = env.get(config.auth_token_env) or env.get(config.env_key) or "dummy-api-key" + env[config.env_key] = token + env[config.auth_token_env] = token + + # Isolate config/settings/state under a temp directory + env["HOME"] = str(cfg_root) + + return env + + def build_command(self, *, cfg_root: Path, prompt: str, config: ClaudeCLIConfig) -> list[str]: + if not prompt or not prompt.strip(): + raise ValueError("Prompt cannot be empty") + + mcp_cfg_path = (cfg_root / "mcp.json").absolute() + self._mcp_config_path = mcp_cfg_path + + # Rewrite localhost addresses to host gateway for container runners + mcp_host = config.mcp_host + from ..command_runner import ContainerRunner + + if isinstance(self.runner, ContainerRunner) and mcp_host in ("0.0.0.0", "127.0.0.1", "localhost"): + mcp_host = self.runner.host_gateway + + mcp_url = f"http://{mcp_host}:{config.mcp_port}/mcp" + self._write_mcp_config(mcp_cfg_path, mcp_url) + self._write_settings_config(cfg_root) + + # Verify config was written + if not mcp_cfg_path.exists(): + raise RuntimeError(f"Failed to write MCP config to {mcp_cfg_path}") + + cmd: list[str] = [ + "claude", + "-p", # print mode, single-shot + "--model", + config.claude_model, + "--mcp-config", + str(mcp_cfg_path), + "--strict-mcp-config", + "--output-format", + config.output_format, + "--debug", + "--mcp-debug", + "--no-session-persistence", + "--append-system-prompt", + "AUTONOMOUS SOLO MODE.\n" + "First, discover available MCP tools by listing tools from the environment server.\n" + "Then use those tools to complete the task.\n" + "Never ask for clarification - make reasonable assumptions.\n" + "You have NO filesystem access - work exclusively through MCP tools.", + "--dangerously-skip-permissions", + "--max-turns", + str(config.max_turns), + ] + + # if config.allowed_tools: + # cmd.extend(["--allowedTools", ",".join(config.allowed_tools)]) + + # Ensure the following argument is treated purely as the prompt, not part of a variadic flag. + cmd.append("--") + cmd.append(prompt) + return cmd + + # Internal helpers ------------------------------------------------- + + def _write_mcp_config(self, path: Path, mcp_url: str) -> None: + """Write MCP server configuration.""" + config = {"mcpServers": {"environment": {"type": "http", "url": mcp_url}}} + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + json.dump(config, fh, indent=2) + + def _write_settings_config(self, home_dir: Path) -> None: + """Write settings that completely block filesystem access.""" + settings_path = home_dir / ".claude" / "settings.json" + + settings = { + "enableAllProjectMcpServers": True, + } + + settings_path.parent.mkdir(parents=True, exist_ok=True) + with open(settings_path, "w", encoding="utf-8") as fh: + json.dump(settings, fh, indent=2) + + # Pre-create directories that the Claude Code CLI expects to write + # into. When running inside a container with ``--user``, the + # mounted volume may have restrictive ownership so the CLI cannot + # create these itself. + for subdir in ("debug", "conversations", "projects", "todos"): + (home_dir / ".claude" / subdir).mkdir(parents=True, exist_ok=True) diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/cli/claude/setup.sh b/labs/AgentStream/exgentic/src/exgentic/agents/cli/claude/setup.sh new file mode 100644 index 00000000..dc85320f --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/cli/claude/setup.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -euo pipefail + +# Determine container runtime +CONTAINER_CMD="" +if command -v podman >/dev/null 2>&1; then + CONTAINER_CMD="podman" + # Start podman machine if needed (macOS/Windows) + if podman machine list >/dev/null 2>&1; then + MACHINE_STATUS=$(podman machine list --format "{{.Running}}" 2>/dev/null | head -n 1) + if [ -z "$MACHINE_STATUS" ]; then + podman machine init && podman machine start + elif [ "$MACHINE_STATUS" != "true" ]; then + podman machine start + fi + fi +elif command -v docker >/dev/null 2>&1; then + CONTAINER_CMD="docker" +else + echo "Error: Neither Podman nor Docker found." >&2 + exit 1 +fi + +# Build Claude Code container image (inline — no external Dockerfile needed) +$CONTAINER_CMD build -t exgentic-claude-code:dev -f - . <<'DOCKERFILE' +FROM registry.access.redhat.com/ubi9/nodejs-20 +RUN npm install -g @anthropic-ai/claude-code@2.1.7 +WORKDIR /work +CMD ["claude","--help"] +DOCKERFILE + +echo "Claude Code Agent setup complete (using $CONTAINER_CMD)" diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/cli/codex/__init__.py b/labs/AgentStream/exgentic/src/exgentic/agents/cli/codex/__init__.py new file mode 100644 index 00000000..38ee1b99 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/cli/codex/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from .agent import CodexAgent, CodexAgentInstance # noqa: F401 diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/cli/codex/agent.py b/labs/AgentStream/exgentic/src/exgentic/agents/cli/codex/agent.py new file mode 100644 index 00000000..0f9e5a98 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/cli/codex/agent.py @@ -0,0 +1,71 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import os +from typing import Any, ClassVar + +from ....core.types import ModelSettings +from ..base import ProxyBackedAgent, ProxyBackedMCPAgentInstance +from .cli import CodexCLI, CodexCLIConfig, ExecutionBackend + + +class CodexAgentInstance(ProxyBackedMCPAgentInstance): + """Self-contained Codex CLI agent that runs through a LiteLLM proxy.""" + + def __init__( + self, + session_id: str, + model_id: str, + max_steps: int = 150, + execution_backend: ExecutionBackend = ExecutionBackend.AUTO, + model_settings: ModelSettings | None = None, + ): + super().__init__( + session_id, + model_id, + max_steps=max_steps, + execution_backend=execution_backend, + model_settings=model_settings, + ) + self._codex_log = self.paths.agent_dir / "codex_cli.log" + + @property + def cli_display_name(self) -> str: + return "Codex CLI" + + def _build_cli(self) -> CodexCLI: + return CodexCLI( + env=os.environ.copy(), + log_path=self._codex_log, + logger=self.logger, + runner=self.execution_backend, + ) + + def _run_cli( + self, + cli: CodexCLI, + prompt: str, + mcp_host: str, + mcp_port: int, + proxy: Any, + ) -> Any: + config = CodexCLIConfig( + mcp_host=mcp_host, + mcp_port=mcp_port, + model_id=self.model_id, + provider_url=proxy.base_url, + ) + result = cli.run(prompt=prompt, config=config) + return result.stdout + + +class CodexAgent(ProxyBackedAgent): + display_name: ClassVar[str] = "Codex CLI" + slug_name: ClassVar[str] = "codex_cli" + execution_backend: ExecutionBackend = ExecutionBackend.AUTO + + @classmethod + def _get_instance_class(cls): + return CodexAgentInstance diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/cli/codex/cli.py b/labs/AgentStream/exgentic/src/exgentic/agents/cli/codex/cli.py new file mode 100644 index 00000000..1d15a2e7 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/cli/codex/cli.py @@ -0,0 +1,70 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +from ..base import BaseCLIConfig, BaseCLIWrapper, ExecutionBackend + + +class CodexCLIConfig(BaseCLIConfig): + model_id: str + env_key: str = "OPENAI_API_KEY" + profile: str = "temp_model" + provider_name: str = "Provider" + image: str = "exgentic-codex:dev" + + +class CodexCLI(BaseCLIWrapper): + """Standalone wrapper for launching Codex CLI.""" + + def __init__( + self, + env: Optional[dict[str, str]] = None, + log_path: Optional[Path] = None, + logger=None, + runner: ExecutionBackend = ExecutionBackend.PROCESS, + ) -> None: + super().__init__(env=env, log_path=log_path, config_dir=None, logger=logger, runner=runner) + self.config_prefix = "codex_cli_" + self.spawn_error_message = "Failed to start Codex CLI" + + def build_env(self, *, cfg_root: Path, prompt: str, config: CodexCLIConfig) -> dict[str, str]: + env = self.env.copy() + env["OPENAI_API_BASE"] = config.provider_url + api_key = env.get(config.env_key) or "dummy-api-key" + env[config.env_key] = api_key + return env + + def build_command(self, *, cfg_root: Path, prompt: str, config: CodexCLIConfig) -> list[str]: + # Rewrite localhost addresses to host gateway for container runners + mcp_host = config.mcp_host + provider_url = config.provider_url + from ..command_runner import ContainerRunner + + if isinstance(self.runner, ContainerRunner): + if mcp_host in ("0.0.0.0", "127.0.0.1", "localhost"): + mcp_host = self.runner.host_gateway + for local in ("://127.0.0.1:", "://localhost:"): + if local in provider_url: + provider_url = provider_url.replace(local, f"://{self.runner.host_gateway}:") + break + + mcp_url = f"http://{mcp_host}:{config.mcp_port}/mcp" + overrides = [ + f'mcp_servers.environment.url="{mcp_url}"', + f'model_providers.temp.name="{config.provider_name}"', + f'model_providers.temp.base_url="{provider_url}"', + f'model_providers.temp.env_key="{config.env_key}"', + f'profiles.{config.profile}.model_provider="temp"', + f'profiles.{config.profile}.model="{config.model_id}"', + ] + + cmd: list[str] = ["codex", "exec", "--skip-git-repo-check", "--profile", config.profile] + for override in overrides: + cmd.extend(["-c", override]) + cmd.append(prompt) + + return cmd diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/cli/codex/setup.sh b/labs/AgentStream/exgentic/src/exgentic/agents/cli/codex/setup.sh new file mode 100644 index 00000000..80c8e0fd --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/cli/codex/setup.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -euo pipefail + +# Determine container runtime +CONTAINER_CMD="" +if command -v podman >/dev/null 2>&1; then + CONTAINER_CMD="podman" + # Start podman machine if needed (macOS/Windows) + if podman machine list >/dev/null 2>&1; then + MACHINE_STATUS=$(podman machine list --format "{{.Running}}" 2>/dev/null | head -n 1) + if [ -z "$MACHINE_STATUS" ]; then + podman machine init && podman machine start + elif [ "$MACHINE_STATUS" != "true" ]; then + podman machine start + fi + fi +elif command -v docker >/dev/null 2>&1; then + CONTAINER_CMD="docker" +else + echo "Error: Neither Podman nor Docker found." >&2 + exit 1 +fi + +# Build Codex CLI container image (inline — no external Dockerfile needed) +$CONTAINER_CMD build -t exgentic-codex:dev -f - . <<'DOCKERFILE' +FROM registry.access.redhat.com/ubi9/nodejs-20 +RUN npm install -g @openai/codex@0.93.0 +WORKDIR /work +CMD ["codex","--help"] +DOCKERFILE + +echo "Codex Agent setup complete (using $CONTAINER_CMD)" diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/cli/command_runner.py b/labs/AgentStream/exgentic/src/exgentic/agents/cli/command_runner.py new file mode 100644 index 00000000..3dd2a26d --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/cli/command_runner.py @@ -0,0 +1,471 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import json +import os +import shlex +import shutil +import subprocess +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import Optional + +from pydantic import BaseModel + + +class ExecutionBackend(str, Enum): + """Execution backend for CLI runs.""" + + PROCESS = "process" + PODMAN = "podman" + DOCKER = "docker" + AUTO = "auto" + + +def resolve_container_backend() -> ExecutionBackend: + """Auto-detect container runtime: prefer podman, fallback to docker.""" + if shutil.which("podman"): + return ExecutionBackend.PODMAN + if shutil.which("docker"): + return ExecutionBackend.DOCKER + raise RuntimeError("Neither podman nor docker found") + + +@dataclass +class CLIResult: + stdout: str + stderr: str + code: int + + +class CLIStartError(RuntimeError): + """Raised when a CLI fails to spawn.""" + + +class CLIExecutionError(RuntimeError): + """Raised when a CLI exits with a non-zero status.""" + + def __init__( + self, + message: str, + *, + code: int, + stdout: str, + stderr: str, + cmd: list[str], + ) -> None: + super().__init__(message) + self.code = code + self.stdout = stdout + self.stderr = stderr + self.cmd = cmd + + def __str__(self) -> str: + parts = [super().__str__()] + if self.stderr: + parts.append(f"STDERR:\n{self.stderr.rstrip()}") + if self.stdout: + parts.append(f"STDOUT:\n{self.stdout.rstrip()}") + return "\n".join(parts) + + +class BaseCLIConfig(BaseModel): + """Common config fields shared by CLI wrappers.""" + + mcp_host: str + mcp_port: int + provider_url: str + image: str + image_workdir: str = "/work" + env: Optional[dict[str, str]] = None + + +class ProcessRunner: + """Shared subprocess execution logic (spawn + communicate + timeout + kill). + + Concrete runners implement how cmd/env/cfg_root are transformed. + """ + + def __init__(self, log_path, logger): + super().__init__() + self.log_path = log_path + self._logger = logger + self._last_cmd: list[str] = [] + self._proc: Optional[subprocess.Popen[str]] = None + + def _write_log( + self, + stdout: str, + stderr: str, + *, + returncode: int, + config: BaseCLIConfig, + ) -> None: + if not self.log_path: + return + self.log_path.parent.mkdir(parents=True, exist_ok=True) + try: + config_json = config.model_dump_json(indent=2) + except Exception: + config_json = str(config) + with open(self.log_path, "w", encoding="utf-8") as fh: + fh.write(f"Command: {shlex.join(self._last_cmd)}\n") + fh.write("Config:\n") + fh.write(f"{config_json}\n") + fh.write(f"Exit code: {returncode}\n\n") + if stdout: + fh.write("STDOUT:\n") + fh.write(stdout) + if not stdout.endswith("\n"): + fh.write("\n") + if stderr: + if stdout: + fh.write("\n") + fh.write("STDERR:\n") + fh.write(stderr) + if not stderr.endswith("\n"): + fh.write("\n") + + def run( + self, + *, + cmd: list[str], + env: dict[str, str], + cfg_root: Path, + config: BaseCLIConfig, + spawn_error_message: str, + stdin_devnull: bool = False, + ) -> CLIResult: + self._last_cmd = cmd + stdout: str = "" + stderr: str = "" + code: int = -1 + + popen_stdin = subprocess.DEVNULL if stdin_devnull else None + timeout_s: Optional[float] = None + + try: + self._proc = subprocess.Popen( + cmd, + stdin=popen_stdin, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + except Exception as exc: + stderr = f"{spawn_error_message}: {exc}" + self._write_log(stdout, stderr, returncode=code, config=config) + raise CLIStartError(f"{spawn_error_message}: {exc}") from exc + + try: + try: + stdout, stderr = self._proc.communicate(timeout=timeout_s) + except subprocess.TimeoutExpired: + self._logger.warning("CLI timed out; terminating: %s", shlex.join(cmd)) + self._proc.terminate() + try: + stdout, stderr = self._proc.communicate(timeout=5) + except subprocess.TimeoutExpired: + self._logger.warning("CLI did not terminate; killing it: %s", shlex.join(cmd)) + self._proc.kill() + stdout, stderr = self._proc.communicate() + code = self._proc.returncode or 0 + finally: + self._write_log(stdout or "", stderr or "", returncode=code, config=config) + if code != 0: + self._logger.warning("CLI exited non-zero (%s): %s", code, shlex.join(cmd)) + if code != 0: + raise CLIExecutionError( + f"CLI exited non-zero ({code}): {shlex.join(cmd)}", + code=code, + stdout=stdout or "", + stderr=stderr or "", + cmd=cmd, + ) + return CLIResult(stdout=stdout or "", stderr=stderr or "", code=code) + + def close(self) -> None: + proc = self._proc + if self._proc and self._proc.poll() is None: + self._proc.terminate() + try: + self._proc.wait(timeout=5) + except subprocess.TimeoutExpired: + self._logger.warning("CLI process did not terminate; killing it.") + self._proc.kill() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + self._logger.warning("CLI process did not exit after kill.") + + +class ContainerRunner(ProcessRunner): + """Shared helpers for container-based runners.""" + + host_gateway: str = "host.docker.internal" + + def _rewrite_mcp_config_path(self, inner_cmd: list[str], workdir: str) -> list[str]: + if "--mcp-config" in inner_cmd: + i = inner_cmd.index("--mcp-config") + if i + 1 < len(inner_cmd): + inner_cmd[i + 1] = f"{workdir}/mcp.json" + return inner_cmd + + def _container_env_from( + self, + env: dict[str, str], + host_gateway: str, + config: BaseCLIConfig, + ) -> dict[str, str]: + forwarded: dict[str, str] = {} + + for k in ( + # Anthropic (Claude Code) + "ANTHROPIC_BASE_URL", + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + # OpenAI (Codex) + "OPENAI_API_KEY", + "OPENAI_API_BASE", + # Google (Gemini) + "GEMINI_API_KEY", + "GOOGLE_GEMINI_BASE_URL", + ): + if k in env: + forwarded[k] = env[k] + for k, v in env.items(): + if k.startswith("EXGENTIC_CTX_"): + forwarded[k] = v + if config.env: + for k, v in config.env.items(): + forwarded[k] = v + + for k in ( + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + ): + if k in env: + forwarded[k] = env[k] + + if "PYTHONIOENCODING" in env: + forwarded["PYTHONIOENCODING"] = env["PYTHONIOENCODING"] + + for k, v in forwarded.items(): + if "://127.0.0.1:" in v: + forwarded[k] = v.replace("://127.0.0.1:", f"://{host_gateway}:") + if "://localhost:" in v: + forwarded[k] = v.replace("://localhost:", f"://{host_gateway}:") + + # Ensure host_gateway is excluded from proxy so container can reach + # host-side services (LiteLLM proxy, MCP server) directly. + for k in ("NO_PROXY", "no_proxy"): + existing = forwarded.get(k, "") + if host_gateway not in existing: + forwarded[k] = f"{existing},{host_gateway}" if existing else host_gateway + + return forwarded + + def _patch_mcp_json(self, *, cfg_root: Path, host_gateway: str) -> None: + mcp_path = cfg_root / "mcp.json" + if not mcp_path.exists(): + return + + try: + data = json.loads(mcp_path.read_text(encoding="utf-8")) + env_cfg = data.get("mcpServers", {}).get("environment", {}) + url = env_cfg.get("url", "") + if not isinstance(url, str) or not url: + return + + if "://127.0.0.1:" in url: + data["mcpServers"]["environment"]["url"] = url.replace("://127.0.0.1:", f"://{host_gateway}:") + mcp_path.write_text(json.dumps(data, indent=2), encoding="utf-8") + self._logger.debug("Patched mcp.json for container host gateway: %s", mcp_path) + elif "://localhost:" in url: + data["mcpServers"]["environment"]["url"] = url.replace("://localhost:", f"://{host_gateway}:") + mcp_path.write_text(json.dumps(data, indent=2), encoding="utf-8") + self._logger.debug("Patched mcp.json for container host gateway: %s", mcp_path) + + except Exception as exc: + self._logger.warning("Failed to patch mcp.json (%s): %s", mcp_path, exc) + + +class PodmanRunner(ContainerRunner): + """Run the inner command inside a container via Podman.""" + + host_gateway = "host.containers.internal" + + def __init__(self, log_path, logger): + super().__init__(log_path, logger) + + def run( + self, + *, + cmd: list[str], + env: dict[str, str], + cfg_root: Path, + config: BaseCLIConfig, + spawn_error_message: str, + ) -> CLIResult: + runtime = "podman" + host_gateway = "host.containers.internal" + host_cfg_root = str(cfg_root.resolve()) + + # Patch mcp.json so container uses host gateway (not 127.0.0.1/localhost) + self._patch_mcp_json(cfg_root=cfg_root, host_gateway=host_gateway) + + inner_cmd = self._rewrite_mcp_config_path(list(cmd), workdir=config.image_workdir) + + # Minimal env forwarding into container (encoded via podman -e flags) + container_env = self._container_env_from(env, host_gateway=host_gateway, config=config) + container_env["HOME"] = config.image_workdir + connection_args = self._resolve_podman_connection_args() + user_args: list[str] = [] + uid = getattr(os, "getuid", None) + gid = getattr(os, "getgid", None) + if callable(uid) and callable(gid): + try: + user_args = ["--user", f"{uid()}:{gid()}"] + except Exception: + user_args = [] + wrapped_cmd: list[str] = [ + runtime, + *connection_args, + "run", + "--rm", + *user_args, + "-v", + f"{host_cfg_root}:{config.image_workdir}:Z", + "-w", + config.image_workdir, + ] + for k, v in container_env.items(): + wrapped_cmd.extend(["-e", f"{k}={v}"]) + wrapped_cmd.append(str(config.image)) + wrapped_cmd.extend(inner_cmd) + + # Important: do NOT keep stdin open (avoids "podman run never ends") + return super().run( + cmd=wrapped_cmd, + env=env, + cfg_root=cfg_root, + config=config, + spawn_error_message=spawn_error_message, + stdin_devnull=True, + ) + + def _resolve_podman_connection_args(self) -> list[str]: + """Return extra args for the `podman` CLI to select a connection. + + Priority: + 1) PODMAN_CONNECTION env var + 2) auto-detect default connection from `podman system connection list --format json` + 3) fallback: no args (let Podman decide; works on native Linux / preconfigured env) + """ + # 1) environment override (nice for CI/users) + env_name = os.environ.get("PODMAN_CONNECTION") + if env_name: + return ["--connection", env_name] + + # 2) auto-detect default connection + try: + proc = subprocess.run( + ["podman", "system", "connection", "list", "--format", "json"], + check=False, + capture_output=True, + text=True, + ) + if proc.returncode != 0: + self._logger.info( + "podman system connection list failed (rc=%s): %s", + proc.returncode, + (proc.stderr or "").strip(), + ) + return [] + + data = json.loads(proc.stdout or "[]") + # entries look like: {"Name": "...", "URI": "...", "Identity": "...", "Default": true, ...} + default = next((x for x in data if x.get("Default") is True), None) + if default and default.get("Name"): + return [ + "--url", + str(default["URI"]), + "--identity", + str(default["Identity"]), + ] + except Exception as exc: + self._logger.info("Failed to auto-detect Podman connection: %r", exc) + return [] + + return [] + + +class DockerRunner(ContainerRunner): + """Run the inner command inside a container via Docker.""" + + def __init__(self, log_path, logger): + super().__init__(log_path, logger) + + def run( + self, + *, + cmd: list[str], + env: dict[str, str], + cfg_root: Path, + config: BaseCLIConfig, + spawn_error_message: str, + ) -> CLIResult: + runtime = "docker" + host_gateway = "host.docker.internal" + host_cfg_root = str(cfg_root.resolve()) + + # Patch mcp.json so container uses host gateway (not 127.0.0.1/localhost) + self._patch_mcp_json(cfg_root=cfg_root, host_gateway=host_gateway) + + inner_cmd = self._rewrite_mcp_config_path(list(cmd), workdir=config.image_workdir) + + # Minimal env forwarding into container (encoded via docker -e flags) + container_env = self._container_env_from(env, host_gateway=host_gateway, config=config) + container_env["HOME"] = config.image_workdir + user_args: list[str] = [] + uid = getattr(os, "getuid", None) + gid = getattr(os, "getgid", None) + if callable(uid) and callable(gid): + try: + user_args = ["--user", f"{uid()}:{gid()}"] + except Exception: + user_args = [] + wrapped_cmd: list[str] = [ + runtime, + "run", + "--rm", + "--add-host", + f"{host_gateway}:host-gateway", + *user_args, + "-v", + f"{host_cfg_root}:{config.image_workdir}", + "-w", + config.image_workdir, + ] + for k, v in container_env.items(): + wrapped_cmd.extend(["-e", f"{k}={v}"]) + wrapped_cmd.append(str(config.image)) + wrapped_cmd.extend(inner_cmd) + + # Important: do NOT keep stdin open (avoids "docker run never ends") + return super().run( + cmd=wrapped_cmd, + env=env, + cfg_root=cfg_root, + config=config, + spawn_error_message=spawn_error_message, + stdin_devnull=True, + ) diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/cli/gemini/__init__.py b/labs/AgentStream/exgentic/src/exgentic/agents/cli/gemini/__init__.py new file mode 100644 index 00000000..f14bde1d --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/cli/gemini/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from .agent import GeminiAgent, GeminiAgentInstance # noqa: F401 diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/cli/gemini/agent.py b/labs/AgentStream/exgentic/src/exgentic/agents/cli/gemini/agent.py new file mode 100644 index 00000000..19067ccf --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/cli/gemini/agent.py @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import os +from typing import Any, ClassVar + +from ....core.types import ModelSettings +from ..base import ExecutionBackend, ProxyBackedAgent, ProxyBackedMCPAgentInstance +from .cli import GeminiCLI, GeminiCLIConfig + + +class GeminiAgentInstance(ProxyBackedMCPAgentInstance): + """Self-contained Gemini CLI agent that runs through a LiteLLM proxy.""" + + def __init__( + self, + session_id: str, + model_id: str, + max_steps: int = 150, + execution_backend: ExecutionBackend = ExecutionBackend.AUTO, + model_settings: ModelSettings | None = None, + ): + self._gemini_model_alias = "gemini-2.5-pro" + super().__init__( + session_id, + model_id, + max_steps=max_steps, + model_alias=self._gemini_model_alias, + execution_backend=execution_backend, + model_settings=model_settings, + ) + self._gemini_log = self.paths.agent_dir / "gemini_cli.log" + + @property + def cli_display_name(self) -> str: + return "Gemini CLI" + + def _build_cli(self) -> GeminiCLI: + cfg_dir = self.paths.agent_dir / "gemini_config" + return GeminiCLI( + env=os.environ.copy(), + log_path=self._gemini_log, + config_dir=cfg_dir, + logger=self.logger, + runner=self.execution_backend, + ) + + def _run_cli( + self, + cli: GeminiCLI, + prompt: str, + mcp_host: str, + mcp_port: int, + proxy: Any, + ) -> Any: + config = GeminiCLIConfig( + mcp_host=mcp_host, + mcp_port=mcp_port, + provider_url=proxy.base_url, + backend_model=self.model_id, + gemini_model=self._gemini_model_alias, + allowed_mcp_server_names=["environment"], + allowed_tools=[action.name for action in self.actions], + ) + result = cli.run(prompt=prompt, config=config) + return result.stdout + + +class GeminiAgent(ProxyBackedAgent): + display_name: ClassVar[str] = "Gemini CLI" + slug_name: ClassVar[str] = "gemini_cli" + execution_backend: ExecutionBackend = ExecutionBackend.AUTO + + @classmethod + def _get_instance_class(cls): + return GeminiAgentInstance + + def get_models_names(self) -> list[str]: # type: ignore[override] + return [str(self.model_id)] diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/cli/gemini/cli.py b/labs/AgentStream/exgentic/src/exgentic/agents/cli/gemini/cli.py new file mode 100644 index 00000000..935d3fa7 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/cli/gemini/cli.py @@ -0,0 +1,112 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional + +from ..base import BaseCLIConfig, BaseCLIWrapper, ExecutionBackend + + +class GeminiCLIConfig(BaseCLIConfig): + backend_model: str + gemini_model: str = "gemini-2.5-pro" + env_key: str = "GEMINI_API_KEY" + server_name: str = "environment" + output_format: str = "text" + approval_mode: str = "yolo" + allowed_mcp_server_names: Optional[list[str]] = None + allowed_tools: Optional[list[str]] = None + image: str = "exgentic-gemini:dev" + + +class GeminiCLI(BaseCLIWrapper): + """Lightweight wrapper for running the Gemini CLI headlessly.""" + + def __init__( + self, + env: Optional[dict[str, str]] = None, + log_path: Optional[Path] = None, + config_dir: Optional[Path] = None, + logger=None, + runner: ExecutionBackend = ExecutionBackend.PROCESS, + ) -> None: + super().__init__( + env=env, + log_path=log_path, + config_dir=config_dir, + logger=logger, + runner=runner, + ) + self._settings_path: Optional[Path] = None + self.config_prefix = "gemini_cli_" + self.spawn_error_message = "Failed to start Gemini CLI" + + # Required hooks -------------------------------------------------- + + def build_env(self, *, cfg_root: Path, prompt: str, config: GeminiCLIConfig) -> dict[str, str]: + env = self.env.copy() + env["GOOGLE_GEMINI_BASE_URL"] = config.provider_url + + api_key = env.get(config.env_key) or env.get("GEMINI_API_KEY") or "dummy-api-key" + env[config.env_key] = api_key + env["GEMINI_API_KEY"] = api_key + + env["HOME"] = str(cfg_root) + return env + + def build_command(self, *, cfg_root: Path, prompt: str, config: GeminiCLIConfig) -> list[str]: + gemini_cfg_dir = cfg_root / ".gemini" + settings_path = gemini_cfg_dir / "settings.json" + self._settings_path = settings_path + + # Rewrite localhost addresses to host gateway for container runners + mcp_host = config.mcp_host + from ..command_runner import ContainerRunner + + if isinstance(self.runner, ContainerRunner) and mcp_host in ("0.0.0.0", "127.0.0.1", "localhost"): + mcp_host = self.runner.host_gateway + + gemini_cfg_dir.mkdir(parents=True, exist_ok=True) + mcp_url = f"http://{mcp_host}:{config.mcp_port}/mcp" + self._ensure_settings(settings_path, config.server_name, mcp_url) + + cmd: list[str] = [ + "gemini", + "--model", + config.gemini_model, + "--output-format", + config.output_format, + "--approval-mode", + config.approval_mode, + ] + if config.allowed_mcp_server_names: + cmd.extend(["--allowed-mcp-server-names", *config.allowed_mcp_server_names]) + if config.allowed_tools: + cmd.extend(["--allowed-tools", *config.allowed_tools]) + cmd.append(prompt) + return cmd + + # Internal helpers ------------------------------------------------- + + def _ensure_settings(self, settings_path: Path, server_name: str, mcp_url: str) -> None: + settings: dict[str, object] = {} + if settings_path.exists(): + try: + with open(settings_path, encoding="utf-8-sig") as fh: + settings = json.load(fh) + except Exception: + settings = {} + + servers = settings.setdefault("mcpServers", {}) + if not isinstance(servers, dict): + servers = {} + settings["mcpServers"] = servers + + servers[server_name] = {"httpUrl": mcp_url, "trust": True} + + settings_path.parent.mkdir(parents=True, exist_ok=True) + with open(settings_path, "w", encoding="utf-8") as fh: + json.dump(settings, fh, indent=2) diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/cli/gemini/setup.sh b/labs/AgentStream/exgentic/src/exgentic/agents/cli/gemini/setup.sh new file mode 100644 index 00000000..f279d55d --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/cli/gemini/setup.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -euo pipefail + +# Determine container runtime +CONTAINER_CMD="" +if command -v podman >/dev/null 2>&1; then + CONTAINER_CMD="podman" + # Start podman machine if needed (macOS/Windows) + if podman machine list >/dev/null 2>&1; then + MACHINE_STATUS=$(podman machine list --format "{{.Running}}" 2>/dev/null | head -n 1) + if [ -z "$MACHINE_STATUS" ]; then + podman machine init && podman machine start + elif [ "$MACHINE_STATUS" != "true" ]; then + podman machine start + fi + fi +elif command -v docker >/dev/null 2>&1; then + CONTAINER_CMD="docker" +else + echo "Error: Neither Podman nor Docker found." >&2 + exit 1 +fi + +# Build Gemini CLI container image (inline — no external Dockerfile needed) +$CONTAINER_CMD build -t exgentic-gemini:dev -f - . <<'DOCKERFILE' +FROM registry.access.redhat.com/ubi9/nodejs-20 +RUN npm install -g @google/gemini-cli@0.25.0 +WORKDIR /work +CMD ["gemini","--help"] +DOCKERFILE + +echo "Gemini Agent setup complete (using $CONTAINER_CMD)" diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/cli/requirements.txt b/labs/AgentStream/exgentic/src/exgentic/agents/cli/requirements.txt new file mode 100644 index 00000000..57fa7d59 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/cli/requirements.txt @@ -0,0 +1 @@ +litellm[proxy] diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/harness/__init__.py b/labs/AgentStream/exgentic/src/exgentic/agents/harness/__init__.py new file mode 100644 index 00000000..e096de68 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/harness/__init__.py @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from .harness_agent import HarnessAgent + +__all__ = ["HarnessAgent"] diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/harness/evolver.py b/labs/AgentStream/exgentic/src/exgentic/agents/harness/evolver.py new file mode 100644 index 00000000..5dea5038 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/harness/evolver.py @@ -0,0 +1,382 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +import json +import logging +import time +from typing import Any, Dict, List + +import litellm + +from .harness_store import HarnessStore +from .prompts.evolver import EVOLVER_SYSTEM_PROMPT, build_evolution_user_message +from .retriever import compute_embedding + +logger = logging.getLogger(__name__) + +EVOLVER_TOOLS = [ + { + "type": "function", + "function": { + "name": "read_prompt", + "description": "Read the current system prompt.", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "read_memory", + "description": "Read the current long-term memory document.", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "list_skills", + "description": "List all skills with their names and descriptions.", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "read_skill", + "description": "Read the full body of a specific skill.", + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "The skill name to read."}, + }, + "required": ["name"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "edit_prompt", + "description": "Replace the entire system prompt with new content.", + "parameters": { + "type": "object", + "properties": { + "body": {"type": "string", "description": "The full new system prompt text."}, + }, + "required": ["body"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "edit_memory", + "description": "Replace the entire long-term memory document with new content.", + "parameters": { + "type": "object", + "properties": { + "body": {"type": "string", "description": "The full new memory document text."}, + }, + "required": ["body"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "add_skill", + "description": "Add a new skill to the skill library.", + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Unique skill name."}, + "description": {"type": "string", "description": "One-line description of what the skill does."}, + "body": {"type": "string", "description": "Full skill content/instructions."}, + }, + "required": ["name", "description", "body"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "edit_skill", + "description": "Modify an existing skill's description and/or body.", + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "The skill name to edit."}, + "description": {"type": "string", "description": "New description (optional, omit to keep current)."}, + "body": {"type": "string", "description": "New body (optional, omit to keep current)."}, + }, + "required": ["name"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "delete_skill", + "description": "Delete a skill from the skill library.", + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "The skill name to delete."}, + }, + "required": ["name"], + }, + }, + }, +] + + +# ================================================================== # +# Tool executor +# ================================================================== # + +class _EvolverToolExecutor: + """Executes evolver tools against a HarnessStore. + + Tracks changes and enforces constraints (at most 1 edit_prompt, 1 edit_memory). + """ + + def __init__(self, store: HarnessStore, embedding_model: str) -> None: + self.store = store + self.embedding_model = embedding_model + self.ops_applied: List[str] = [] + self._edit_prompt_used = False + self._edit_memory_used = False + + def execute(self, tool_name: str, args: Dict[str, Any]) -> str: + """Execute a tool call and return the result string.""" + if tool_name == "read_prompt": + return self.store.system_prompt or "(Empty)" + + elif tool_name == "read_memory": + return self.store.memory or "(Empty)" + + elif tool_name == "list_skills": + index = self.store.get_skill_index() + if not index: + return "(No skills yet)" + return json.dumps(index, ensure_ascii=False, indent=2) + + elif tool_name == "read_skill": + name = args.get("name", "") + skills = {s.name: s for s in self.store.list_skills()} + skill = skills.get(name) + if skill is None: + return f"ERROR: Skill '{name}' not found." + return f"Name: {skill.name}\nDescription: {skill.description}\n\n{skill.body}" + + elif tool_name == "edit_prompt": + if self._edit_prompt_used: + return "ERROR: edit_prompt already used this session (limit: 1 per task)." + body = args.get("body", "") + if not body: + return "ERROR: 'body' is required." + self.store.edit_prompt(body) + self._edit_prompt_used = True + self.ops_applied.append("edit_prompt") + return "OK: System prompt updated." + + elif tool_name == "edit_memory": + if self._edit_memory_used: + return "ERROR: edit_memory already used this session (limit: 1 per task)." + body = args.get("body", "") + self.store.edit_memory(body) + self._edit_memory_used = True + self.ops_applied.append("edit_memory") + return "OK: Memory updated." + + elif tool_name == "add_skill": + name = args.get("name", "") + description = args.get("description", "") + body = args.get("body", "") + if not name or not description or not body: + return "ERROR: 'name', 'description', and 'body' are all required." + # Check if skill already exists + existing = {s.name for s in self.store.list_skills()} + if name in existing: + return f"ERROR: Skill '{name}' already exists. Use edit_skill to modify it." + self.store.add_skill(name, description, body) + # Compute embedding + emb = compute_embedding(description, model=self.embedding_model) + if emb: + self.store.set_embedding(name, emb) + self.ops_applied.append(f"add_skill:{name}") + return f"OK: Skill '{name}' added." + + elif tool_name == "edit_skill": + name = args.get("name", "") + if not name: + return "ERROR: 'name' is required." + description = args.get("description") + body = args.get("body") + if description is None and body is None: + return "ERROR: At least one of 'description' or 'body' must be provided." + success = self.store.edit_skill(name, description=description, body=body) + if not success: + return f"ERROR: Skill '{name}' not found." + if description: + emb = compute_embedding(description, model=self.embedding_model) + if emb: + self.store.set_embedding(name, emb) + self.ops_applied.append(f"edit_skill:{name}") + return f"OK: Skill '{name}' updated." + + elif tool_name == "delete_skill": + name = args.get("name", "") + if not name: + return "ERROR: 'name' is required." + success = self.store.delete_skill(name) + if not success: + return f"ERROR: Skill '{name}' not found." + self.ops_applied.append(f"delete_skill:{name}") + return f"OK: Skill '{name}' deleted." + + else: + return f"ERROR: Unknown tool '{tool_name}'." + + +# ================================================================== # +# Multi-turn evolver loop +# ================================================================== # + +def _run_evolver_loop( + model: str, + system_prompt: str, + user_message: str, + executor: _EvolverToolExecutor, + max_turns: int = 20, +) -> None: + """Run multi-turn evolver with tools. + + The LLM can read harness state, then make changes via tool calls. + Loop ends when LLM stops calling tools (finish_reason != tool_calls). + """ + messages: List[Dict[str, Any]] = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_message}, + ] + + for turn in range(max_turns): + # Call LLM + max_attempts = 3 + response = None + for attempt in range(max_attempts): + try: + response = litellm.completion( + model=model, + messages=messages, + tools=EVOLVER_TOOLS, + temperature=0.0, + ) + break + except Exception as exc: + logger.warning("Evolver loop turn %d attempt %d failed: %s", turn, attempt + 1, exc) + if attempt + 1 >= max_attempts: + logger.error("Evolver loop: all attempts failed at turn %d", turn) + return + time.sleep(2 ** attempt) + + if response is None: + break + + choice = response.choices[0] + message = choice.message + + # Check for tool calls + if hasattr(message, "tool_calls") and message.tool_calls: + # Add assistant message to history + assistant_msg: Dict[str, Any] = {"role": "assistant", "content": message.content or ""} + assistant_msg["tool_calls"] = [ + { + "id": tc.id, + "type": "function", + "function": { + "name": tc.function.name, + "arguments": tc.function.arguments, + }, + } + for tc in message.tool_calls + ] + messages.append(assistant_msg) + + # Execute each tool call + for tc in message.tool_calls: + try: + args = json.loads(tc.function.arguments) if tc.function.arguments else {} + except json.JSONDecodeError: + args = {} + + result = executor.execute(tc.function.name, args) + # Log each tool call with args summary and result preview + args_summary = ", ".join(f"{k}={repr(v)[:60]}" for k, v in args.items()) + logger.info( + "Evolver turn %d: %s(%s) → %s", + turn, tc.function.name, args_summary, result[:120], + ) + messages.append({ + "role": "tool", + "tool_call_id": tc.id, + "content": result, + }) + + continue # Next turn + else: + # LLM finished (no more tool calls) + logger.info("Evolver finished after %d turns", turn + 1) + break + + +# ================================================================== # +# Main evolver entry point +# ================================================================== # + +def run_evolver( + store: HarnessStore, + task: str, + injected_skill_names: List[str], + trajectory: str, + llm_call: Any, # kept for interface compat (unused in multi-turn impl) + evolver_model: str, + embedding_model: str = "all-MiniLM-L6-v2", +) -> tuple[int, List[str]]: + """Run the evolver: multi-turn tool-calling to read and modify harness. + + Returns (number of ops applied, list of op summaries). + """ + # Take snapshot for full rollback on catastrophic failure + full_snapshot = store.snapshot() + + try: + # Build user message + user_message = build_evolution_user_message( + task=task, + injected_skill_names=injected_skill_names, + trajectory=trajectory, + ) + + # Create tool executor + executor = _EvolverToolExecutor(store, embedding_model) + + # Run multi-turn loop + _run_evolver_loop( + model=evolver_model, + system_prompt=EVOLVER_SYSTEM_PROMPT, + user_message=user_message, + executor=executor, + max_turns=20, + ) + + ops_applied = len(executor.ops_applied) + logger.info("Harness evolver: %d ops applied: %s", ops_applied, executor.ops_applied) + return ops_applied, executor.ops_applied + + except Exception as exc: + logger.warning("Harness evolver failed, rolling back: %s", exc) + store.rollback(full_snapshot) + return 0, [] diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/harness/harness_agent.py b/labs/AgentStream/exgentic/src/exgentic/agents/harness/harness_agent.py new file mode 100644 index 00000000..19dcc5db --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/harness/harness_agent.py @@ -0,0 +1,70 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +from typing import Any, ClassVar, Optional + +from pydantic import ConfigDict + +from ...core.agent import Agent +from ...core.types import ModelSettings +from ...utils.settings import RunnerName + + +class HarnessAgent(Agent): + + display_name: ClassVar[str] = "Harness Agent" + slug_name: ClassVar[str] = "harness" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + model: str = "gpt-4o" + evolver_model: Optional[str] = None + + top_k_skills: int = 3 + embedding_model: str = "all-MiniLM-L6-v2" + + shuffle_mode: str = "isolated" + + benchmark_id: Optional[str] = None + + enable_tool_shortlisting: bool = False + max_selected_tools: int = 30 + + runner: RunnerName | None = None + model_settings: ModelSettings | None = None + + @classmethod + def _get_instance_class(cls): + from .harness_instance import HarnessAgentInstance + return HarnessAgentInstance + + @classmethod + def _get_instance_class_ref(cls) -> str: + return "exgentic.agents.harness.harness_instance:HarnessAgentInstance" + + def _get_instance_kwargs(self, session_id: str) -> dict[str, Any]: + return { + "session_id": session_id, + "model": self.model, + "evolver_model": self.evolver_model or self.model, + "top_k_skills": self.top_k_skills, + "embedding_model": self.embedding_model, + "shuffle_mode": self.shuffle_mode, + "model_settings": self.model_settings, + "benchmark_id": self.benchmark_id, + "enable_tool_shortlisting": self.enable_tool_shortlisting, + "max_selected_tools": self.max_selected_tools, + } + + @property + def model_name(self) -> str: + return str(self.model).split("/")[-1] + + def get_models_names(self) -> list[str]: + names = [str(self.model)] + em = self.evolver_model or self.model + if em != self.model: + names.append(str(em)) + return names diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/harness/harness_instance.py b/labs/AgentStream/exgentic/src/exgentic/agents/harness/harness_instance.py new file mode 100644 index 00000000..14fc1d27 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/harness/harness_instance.py @@ -0,0 +1,579 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +import json +import time +from datetime import datetime +from typing import Any, Dict, List, Optional, Union + +import litellm +from litellm import ( + ChatCompletionAssistantMessage, + ChatCompletionSystemMessage, + ChatCompletionToolMessage, + ChatCompletionUserMessage, +) + +from ...core.agent_instance import AgentInstance +from ...core.types import ( + Action, + ActionType, + Message, + MessageAction, + MessageObservation, + MessagePayload, + ModelSettings, + Observation, +) +from ...utils.cost import LiteLLMCostReport +from ...utils.settings import get_settings + +from .evolver import run_evolver +from .harness_store import HarnessStore +from .prompts.inject import build_system_message +from .retriever import retrieve_skills + +try: + from ...agents.litellm_tool_calling.utils import ToolCall, ToolsActionsRegistry +except ImportError: + ToolsActionsRegistry = None + ToolCall = dict + +from ..tool_shortlisting import shortlist_tools + +settings = get_settings() + + +class HarnessAgentInstance(AgentInstance): + + def __init__( + self, + session_id: str, + model: str = "gpt-4o", + evolver_model: str = "gpt-4o", + top_k_skills: int = 3, + embedding_model: str = "all-MiniLM-L6-v2", + shuffle_mode: str = "isolated", + model_settings: Optional[ModelSettings] = None, + benchmark_id: Optional[str] = None, + enable_tool_shortlisting: bool = False, + max_selected_tools: int = 30, + ) -> None: + super().__init__(session_id) + + self.model = model + self.evolver_model = evolver_model + self.top_k_skills = top_k_skills + self.embedding_model = embedding_model + self.shuffle_mode = shuffle_mode + self.benchmark_id = benchmark_id + self.enable_tool_shortlisting = enable_tool_shortlisting + self.max_selected_tools = max_selected_tools + + if model_settings is None: + self._model_settings = ModelSettings() + elif isinstance(model_settings, ModelSettings): + self._model_settings = model_settings + else: + self._model_settings = ModelSettings() + + self._cost = LiteLLMCostReport.initialize_empty(model_name=self.model) + self._store: Optional[HarnessStore] = None + + self.messages: list[ + Union[ + ChatCompletionAssistantMessage, + ChatCompletionToolMessage, + ChatCompletionUserMessage, + ChatCompletionSystemMessage, + ] + ] = [] + self._step_count: int = 0 + + self._registry: Optional[ToolsActionsRegistry] = None + self._all_actions: list[ActionType] = [] + + self._observation_log: List[Dict[str, Any]] = [] + self._action_log: List[Dict[str, Any]] = [] + + self._injected_skill_names: List[str] = [] + + def _log_failure( + self, component: str, error: Exception, context: Dict[str, Any] + ) -> None: + try: + log_path = self.paths.agent_dir / "harness_failures.jsonl" + log_path.parent.mkdir(parents=True, exist_ok=True) + entry = { + "timestamp": datetime.now().isoformat(), + "session_id": self.session_id, + "component": component, + "error_type": type(error).__name__, + "error_message": str(error)[:2000], + **{k: str(v)[:2000] if isinstance(v, str) else v + for k, v in context.items()}, + } + with open(log_path, "a", encoding="utf-8") as fh: + fh.write(json.dumps(entry, ensure_ascii=False) + "\n") + except Exception: + pass + + def start( + self, + task: str, + context: Dict[str, Any], + actions: list[ActionType], + ) -> None: + super().start(task, context, actions) + + self._all_actions = list(self.actions) + if ToolsActionsRegistry is not None: + self._registry = ToolsActionsRegistry(self._all_actions) + + task_group = str( + context.get("task_group") + or context.get("task_id") + or context.get("task_name") + or "default" + ) + self._store = HarnessStore.get_or_create( + shuffle_mode=self.shuffle_mode, + task_group=task_group, + benchmark_id=self.benchmark_id, + ) + self._store.increment_session() + + retrieved = [] + if self._store.skill_count > 0: + try: + retrieved = retrieve_skills( + task_text=task, + store=self._store, + top_k=self.top_k_skills, + embedding_model=self.embedding_model, + ) + except Exception as exc: + self.logger.warning("Harness: skill retrieval failed: %s", exc) + + self._injected_skill_names = [s.name for s, _ in retrieved] + + # Mark retrieved skills as used (LRU) + if self._injected_skill_names: + self._store.touch_skills(self._injected_skill_names) + + # Build system message + system_content = build_system_message( + system_prompt=self._store.system_prompt, + memory=self._store.memory, + retrieved_skills=retrieved, + ) + self._add_message( + ChatCompletionSystemMessage(role="system", content=system_content) + ) + self.logger.info( + "Harness system message built: prompt_len=%d memory_len=%d " + "skills_injected=%s total_system_len=%d", + len(self._store.system_prompt), + len(self._store.memory), + self._injected_skill_names, + len(system_content), + ) + + content_parts: list[Any] = [] + ctx = "" + if self.context: + for k, v in self.context.items(): + if isinstance(v, dict) and v.get("type") == "image_url": + content_parts.append({"type": "image_url", "image_url": {"url": v["data"], "detail": "high"}}) + else: + ctx += f"\n<{k}>\n{v}\n" + + text_content = f"{self.task}\n{ctx}" + if content_parts: + content_parts.insert(0, {"type": "text", "text": text_content}) + self._add_message(ChatCompletionUserMessage(role="user", content=content_parts)) + else: + self._add_message( + ChatCompletionUserMessage(role="user", content=text_content) + ) + + self.logger.info( + "Harness instance started store=%s session_count=%d " + "skill_count=%d skills_injected=%d benchmark=%s tools=%d", + self._store.store_id, + self._store.session_count, + self._store.skill_count, + len(self._injected_skill_names), + self.benchmark_id or "(none)", + len(self._all_actions), + ) + + def react(self, observation: Optional[Observation]) -> Optional[Action]: + self._step_count += 1 + + self._observe(observation) + self._log_observation(observation) + + tools = self._assistant_tools() + response = self._completion( + model=self.model, + messages=self.messages, + tools=tools if tools else None, + ) + + if response is None: + self.logger.error("Harness: LLM returned None response") + return None + + if response.usage: + self._cost.update_cost_from_tokens( + response.usage.prompt_tokens, + response.usage.completion_tokens, + ) + + choice = response["choices"][0] + message = choice["message"] + finish_reason = choice.get("finish_reason") + + if finish_reason == "tool_calls" and self._registry is not None: + tool_calls = self._extract_tool_calls(message) + self._add_message( + ChatCompletionAssistantMessage( + role="assistant", + tool_calls=[ + { + "id": tc["id"], + "type": "function", + "function": { + "name": tc["name"], + "arguments": tc["arguments"], + }, + } + for tc in tool_calls + ], + ) + ) + actions = self._registry.tool_calls_to_action(tool_calls) + + for tc in tool_calls: + self._action_log.append({ + "step": self._step_count, + "action": tc["name"], + "arguments": tc["arguments"], + }) + + self.logger.info("Harness step %d: tool_calls=%s", self._step_count, + [tc["name"] for tc in tool_calls]) + return actions + else: + content = message.content if message.content else "" + self._add_message( + ChatCompletionAssistantMessage( + role="assistant", content=content + ) + ) + self._action_log.append({ + "step": self._step_count, + "action": "message", + "content": content, + }) + self.logger.info("Harness step %d: message response", self._step_count) + return MessageAction(arguments=Message(content=content)) + + def close(self) -> None: + store = self._store + if store is None: + return + + ops_applied = 0 + ops_summary: list[str] = [] + + self.logger.info( + "Harness close: starting evolver trajectory_steps=%d " + "actions=%d observations=%d", + self._step_count, len(self._action_log), len(self._observation_log), + ) + + if self._observation_log or self._action_log: + try: + trajectory = self._build_session_trace() + self.logger.info( + "Harness close: trajectory built length=%d chars", len(trajectory) + ) + ops_applied, ops_summary = run_evolver( + store=store, + task=self.task if self.task else "", + injected_skill_names=self._injected_skill_names, + trajectory=trajectory, + llm_call=self._llm_call_simple, + evolver_model=self.evolver_model, + embedding_model=self.embedding_model, + ) + self.logger.info( + "Harness close: evolver done ops_applied=%d ops=%s", + ops_applied, ops_summary, + ) + except Exception as exc: + self.logger.warning("Harness: evolver failed: %s", exc) + self._log_failure("evolver", exc, { + "observation_count": len(self._observation_log), + "action_count": len(self._action_log), + }) + + if ops_applied > 0: + version = store.commit_version( + session_id=self.session_id, + ops_summary=ops_summary, + ) + self.logger.info( + "Harness close: version committed v=%d skill_count=%d", + version, store.skill_count, + ) + + store.record_learning( + session_id=self.session_id, + task_id=str(self.context.get("task_id", "") if self.context else ""), + benchmark_id=self.benchmark_id or "", + ops_applied=ops_applied, + ops_summary=ops_summary, + ) + + try: + cp = str(self.paths.agent_dir / "harness_checkpoint.json") + store.save_checkpoint(cp) + txt = str(self.paths.agent_dir / "harness_state.md") + store.save_harness_text(txt) + except Exception as exc: + self.logger.warning("Harness: failed to save checkpoint: %s", exc) + + def get_cost(self) -> LiteLLMCostReport: + return self._cost + + def _add_message(self, message: Any) -> None: + self.messages.append(message) + + def _observe(self, observation: Optional[Observation]) -> None: + if observation is None: + return + + observations = observation.to_observation_list() + if observation.is_empty(): + if not any(obs.invoking_actions for obs in observations): + return + + for obs in observations: + if isinstance(obs, MessageObservation) and isinstance( + obs.result, MessagePayload + ): + self._add_message( + ChatCompletionUserMessage( + role="user", content=obs.result.message + ) + ) + continue + + if len(obs.invoking_actions) > 0: + invoking = obs.invoking_actions[0] + if invoking.name == "message": + self._add_message( + ChatCompletionUserMessage( + role="user", content=str(obs) + ) + ) + continue + + action_id = invoking.id + tool_call_id = invoking.id + if not ( + isinstance(tool_call_id, str) + and tool_call_id.startswith("call_") + ): + if self._registry is not None: + tool_call_id = ( + self._registry.action_id_to_tool_call_id.get( + action_id, tool_call_id + ) + ) + + value = obs.result + try: + content = json.dumps( + value, ensure_ascii=False, separators=(",", ":") + ) + except TypeError: + content = str(value) + + if tool_call_id is not None: + self._add_message( + ChatCompletionToolMessage( + role="tool", + tool_call_id=tool_call_id, + content=content, + ) + ) + else: + self._add_message( + ChatCompletionUserMessage( + role="user", + content=f"Tool result: {content}", + ) + ) + else: + self._add_message( + ChatCompletionUserMessage( + role="user", content=str(obs) + ) + ) + + def _log_observation(self, observation: Optional[Observation]) -> None: + if observation is None or observation.is_empty(): + return + + for obs in observation.to_observation_list(): + result = obs.result + if result is None: + continue + + entry: Dict[str, Any] = {"step": self._step_count} + if isinstance(result, str): + entry["content"] = result + elif isinstance(result, dict): + entry["content"] = json.dumps(result, ensure_ascii=False) + else: + entry["content"] = str(result) + + if obs.invoking_actions: + entry["action"] = obs.invoking_actions[0].name + + self._observation_log.append(entry) + + def _assistant_tools(self) -> list[dict[str, Any]]: + if self._registry is None: + return [] + tools = self._registry.openai_tools() + if not self.enable_tool_shortlisting: + return tools + + def _cost_cb(usage): + if usage: + self._cost.update_cost_from_tokens( + usage.prompt_tokens, usage.completion_tokens + ) + + return shortlist_tools( + tools=tools, + max_selected=self.max_selected_tools, + messages=self.messages, + completion_fn=self._completion, + model=self.model, + logger=self.logger, + cost_callback=_cost_cb, + ) + + @staticmethod + def _extract_tool_calls(message: Any) -> list[dict[str, str]]: + if not hasattr(message, "tool_calls") or not message.tool_calls: + return [] + tool_calls = [] + for tc in message.tool_calls: + tool_calls.append({ + "name": tc.function.name, + "arguments": tc.function.arguments, + "id": tc.id, + }) + return tool_calls + + def _completion(self, **kwargs) -> Any: + call_kwargs = self._model_settings.model_dump( + exclude_none=True, + exclude={"num_retries", "retry_after", "retry_strategy"}, + ) + call_kwargs.update(kwargs) + if call_kwargs.get("tools") is None: + call_kwargs.pop("tools", None) + + max_attempts = 3 + for attempt in range(max_attempts): + try: + response = litellm.completion(**call_kwargs) + return response + except Exception as exc: + self.logger.warning( + "Harness LLM call attempt %d/%d failed: %s", + attempt + 1, max_attempts, exc, + ) + if attempt + 1 >= max_attempts: + raise + time.sleep(2 ** attempt) + return None + + def _llm_call_simple( + self, + model: str, + prompt: str, + *, + json_mode: bool = False, + ) -> str: + kwargs: Dict[str, Any] = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.0, + } + if json_mode: + kwargs["response_format"] = {"type": "json_object"} + + max_attempts = 3 + for attempt in range(max_attempts): + try: + resp = litellm.completion(**kwargs) + if resp.usage: + self._cost.update_cost_from_tokens( + resp.usage.prompt_tokens, + resp.usage.completion_tokens, + ) + content = resp.choices[0].message.content + if content is None: + raise ValueError("LLM returned None content") + return content + except Exception as exc: + self.logger.warning( + "Harness simple LLM call attempt %d/%d failed: %s", + attempt + 1, max_attempts, exc, + ) + if attempt + 1 >= max_attempts: + self._log_failure("llm_call", exc, { + "model": model, + "prompt_length": len(prompt), + "attempts": max_attempts, + }) + raise + time.sleep(2 ** attempt) + return "" + + def _build_session_trace(self) -> str: + events: List[Dict[str, Any]] = [] + for entry in self._action_log: + events.append({"type": "action", **entry}) + for entry in self._observation_log: + events.append({"type": "observation", **entry}) + + events.sort(key=lambda e: (e.get("step", 0), 0 if e["type"] == "action" else 1)) + + lines: List[str] = [] + for event in events: + step = event.get("step", "?") + if event["type"] == "action": + action = event.get("action", "?") + args = event.get("arguments", event.get("content", "")) + lines.append(f"[Step {step}] Action: {action}") + if args: + lines.append(f" Args: {str(args)[:500]}") + else: + action = event.get("action", "env") + content = event.get("content", "") + lines.append(f"[Step {step}] Observation from {action}:") + lines.append(f" {str(content)[:500]}") + + return "\n".join(lines) if lines else "(No session trace recorded)" diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/harness/harness_store.py b/labs/AgentStream/exgentic/src/exgentic/agents/harness/harness_store.py new file mode 100644 index 00000000..2c3e3a21 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/harness/harness_store.py @@ -0,0 +1,320 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +import copy +import json +import threading +from dataclasses import asdict, dataclass, field +from datetime import datetime +from typing import Any, Dict, List, Optional + + +DEFAULT_SYSTEM_PROMPT = """\ +You are an expert agent that completes tasks using available tools. +Think step-by-step before acting. +Use available tools to interact with the environment. +When you are confident in your solution, use the finish/submit tool.""" + +DEFAULT_MEMORY = "" + + +@dataclass +class HarnessSkill: + + name: str + description: str + body: str + last_used_session: int = 0 + created_session: int = 0 + + +@dataclass +class VersionEntry: + + version: int + session_id: str + session_count: int + ops_summary: List[str] + timestamp: str = "" + system_prompt: str = "" + memory: str = "" + skills: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class LearningEvent: + + session_id: str + task_id: str + benchmark_id: str + ops_applied: int + ops_summary: List[str] = field(default_factory=list) + timestamp: str = "" + + +class HarnessStore: + + _instances: Dict[str, "HarnessStore"] = {} + _class_lock = threading.Lock() + + def __init__(self, store_id: str) -> None: + self._store_id = store_id + self._lock = threading.Lock() + self.system_prompt: str = DEFAULT_SYSTEM_PROMPT + self.memory: str = DEFAULT_MEMORY + self.skills: Dict[str, HarnessSkill] = {} + self.skill_embeddings: Dict[str, List[float]] = {} + self._session_count: int = 0 + self._history: List[LearningEvent] = [] + self._versions: List[VersionEntry] = [] + self._next_version: int = 0 + + @property + def store_id(self) -> str: + return self._store_id + + @property + def session_count(self) -> int: + with self._lock: + return self._session_count + + @property + def skill_count(self) -> int: + with self._lock: + return len(self.skills) + + @classmethod + def get_or_create( + cls, + shuffle_mode: str = "isolated", + benchmark_id: Optional[str] = None, + task_group: Optional[str] = None, + ) -> "HarnessStore": + if shuffle_mode == "isolated": + key = f"harness_isolated_{benchmark_id or task_group or 'default'}" + elif shuffle_mode == "sequential": + key = "harness_sequential_global" + elif shuffle_mode == "interleaved": + key = "harness_interleaved_global" + else: + key = f"harness_{shuffle_mode}" + + with cls._class_lock: + if key not in cls._instances: + cls._instances[key] = cls(store_id=key) + return cls._instances[key] + + @classmethod + def list_stores(cls) -> Dict[str, "HarnessStore"]: + with cls._class_lock: + return dict(cls._instances) + + @classmethod + def reset_all(cls) -> None: + with cls._class_lock: + cls._instances.clear() + + def increment_session(self) -> int: + with self._lock: + self._session_count += 1 + return self._session_count + + def add_skill(self, name: str, description: str, body: str) -> None: + with self._lock: + self.skills[name] = HarnessSkill( + name=name, + description=description, + body=body, + last_used_session=self._session_count, + created_session=self._session_count, + ) + + def edit_skill( + self, name: str, description: Optional[str] = None, body: Optional[str] = None + ) -> bool: + with self._lock: + skill = self.skills.get(name) + if skill is None: + return False + if description is not None: + skill.description = description + if body is not None: + skill.body = body + return True + + def delete_skill(self, name: str) -> bool: + with self._lock: + if name in self.skills: + del self.skills[name] + self.skill_embeddings.pop(name, None) + return True + return False + + def touch_skills(self, names: List[str]) -> None: + with self._lock: + for name in names: + skill = self.skills.get(name) + if skill: + skill.last_used_session = self._session_count + + def get_skill_index(self) -> List[Dict[str, str]]: + with self._lock: + return [ + {"name": s.name, "description": s.description} + for s in self.skills.values() + ] + + def list_skills(self) -> List[HarnessSkill]: + with self._lock: + return list(self.skills.values()) + + def edit_prompt(self, new_prompt: str) -> None: + with self._lock: + self.system_prompt = new_prompt + + def edit_memory(self, new_memory: str) -> None: + with self._lock: + self.memory = new_memory + + def set_embedding(self, skill_name: str, embedding: List[float]) -> None: + with self._lock: + self.skill_embeddings[skill_name] = embedding + + def get_embeddings(self) -> Dict[str, List[float]]: + with self._lock: + return dict(self.skill_embeddings) + + def snapshot(self) -> Dict[str, Any]: + with self._lock: + return { + "system_prompt": self.system_prompt, + "memory": self.memory, + "skills": copy.deepcopy(self.skills), + "skill_embeddings": copy.deepcopy(self.skill_embeddings), + } + + def rollback(self, snap: Dict[str, Any]) -> None: + with self._lock: + self.system_prompt = snap["system_prompt"] + self.memory = snap["memory"] + self.skills = snap["skills"] + self.skill_embeddings = snap["skill_embeddings"] + + def commit_version(self, session_id: str, ops_summary: List[str]) -> int: + with self._lock: + version = self._next_version + self._next_version += 1 + entry = VersionEntry( + version=version, + session_id=session_id, + session_count=self._session_count, + ops_summary=ops_summary, + timestamp=datetime.now().isoformat(), + system_prompt=self.system_prompt, + memory=self.memory, + skills={name: asdict(s) for name, s in self.skills.items()}, + ) + self._versions.append(entry) + return version + + def record_learning( + self, + session_id: str, + task_id: str, + benchmark_id: str, + ops_applied: int, + ops_summary: Optional[List[str]] = None, + ) -> None: + with self._lock: + self._history.append(LearningEvent( + session_id=session_id, + task_id=task_id, + benchmark_id=benchmark_id, + ops_applied=ops_applied, + ops_summary=ops_summary or [], + timestamp=datetime.now().isoformat(), + )) + + def save_checkpoint(self, path: str) -> None: + with self._lock: + data = { + "store_id": self._store_id, + "session_count": self._session_count, + "system_prompt": self.system_prompt, + "memory": self.memory, + "skills": {name: asdict(s) for name, s in self.skills.items()}, + "skill_embeddings": self.skill_embeddings, + "history": [asdict(e) for e in self._history], + "versions": [ + { + "version": v.version, + "session_id": v.session_id, + "session_count": v.session_count, + "ops_summary": v.ops_summary, + "timestamp": v.timestamp, + "skill_names": list(v.skills.keys()), + } + for v in self._versions + ], + } + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + def load_checkpoint(self, path: str) -> None: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + with self._lock: + self._session_count = data.get("session_count", 0) + self.system_prompt = data.get("system_prompt", DEFAULT_SYSTEM_PROMPT) + self.memory = data.get("memory", DEFAULT_MEMORY) + self.skills = {} + for name, sdata in data.get("skills", {}).items(): + self.skills[name] = HarnessSkill(**{ + k: v for k, v in sdata.items() + if k in HarnessSkill.__dataclass_fields__ + }) + self.skill_embeddings = data.get("skill_embeddings", {}) + self._history = [ + LearningEvent(**{ + k: v for k, v in e.items() + if k in LearningEvent.__dataclass_fields__ + }) + for e in data.get("history", []) + ] + self._versions = [] + for vdata in data.get("versions", []): + self._versions.append(VersionEntry( + version=vdata["version"], + session_id=vdata.get("session_id", ""), + session_count=vdata.get("session_count", 0), + ops_summary=vdata.get("ops_summary", []), + timestamp=vdata.get("timestamp", ""), + skills={name: {} for name in vdata.get("skill_names", [])}, + )) + self._next_version = ( + self._versions[-1].version + 1 if self._versions else 0 + ) + + def save_harness_text(self, path: str) -> None: + with self._lock: + skills = list(self.skills.values()) + lines = [ + f"# Harness State: {self._store_id}", + f"## System Prompt", + self.system_prompt, + "", + f"## Memory", + self.memory, + "", + f"## Skills ({len(skills)})", + ] + for s in skills: + lines.append(f"### {s.name}") + lines.append(f"Description: {s.description}") + lines.append(f"Last used: session {s.last_used_session}") + lines.append(s.body) + lines.append("") + with open(path, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/harness/prompts/__init__.py b/labs/AgentStream/exgentic/src/exgentic/agents/harness/prompts/__init__.py new file mode 100644 index 00000000..d0286a84 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/harness/prompts/__init__.py @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. +from .inject import build_system_message +from .evolver import EVOLVER_SYSTEM_PROMPT, build_evolution_user_message + +__all__ = ["build_system_message", "EVOLVER_SYSTEM_PROMPT", "build_evolution_user_message"] diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/harness/prompts/evolver.py b/labs/AgentStream/exgentic/src/exgentic/agents/harness/prompts/evolver.py new file mode 100644 index 00000000..05a5e014 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/harness/prompts/evolver.py @@ -0,0 +1,52 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +EVOLVER_SYSTEM_PROMPT = """\ +You are an evolution engine for an agent harness. Your job is to analyze a completed task session and improve the agent's harness (system prompt, long-term memory, and skill library) for future tasks. + +## Available Tools + +**Read tools** (use these first to inspect current state): +- read_prompt() — read the current system prompt +- read_memory() — read the current long-term memory document +- list_skills() — list all skills with names and descriptions +- read_skill(name) — read a specific skill's full body + +**Write tools** (use these to make changes): +- edit_prompt(body) — replace the entire system prompt +- edit_memory(body) — replace the entire memory document +- add_skill(name, description, body) — add a new skill +- edit_skill(name, description?, body?) — modify an existing skill +- delete_skill(name) — remove a skill + +## Constraints +- At most 1 edit_prompt call per session. +- At most 1 edit_memory call per session. +- No limit on skill operations. + +## Guidelines +- First READ the current harness state, then decide what changes to make. +- Skills should be generalizable (useful across tasks), not task-specific. +- Memory should capture recurring patterns, proven strategies, and environment quirks. +- System prompt changes should refine the agent's general approach. +- Do NOT duplicate information already present in the harness. +- If no changes are needed, simply stop without calling any write tools. +""" + + +def build_evolution_user_message( + task: str, + injected_skill_names: list[str], + trajectory: str, +) -> str: + parts = [ + "## This Session\n", + f"### Task\n{task}\n", + f"### Skills Injected\n{', '.join(injected_skill_names) if injected_skill_names else '(None)'}\n", + f"### Session Trajectory\n{trajectory}\n", + "\n---\n", + "Analyze the session above. Read the current harness state using the read tools, " + "then decide what changes (if any) would improve the agent's future performance. " + "Make changes using the write tools, or stop if no changes are needed.", + ] + return "\n".join(parts) diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/harness/prompts/inject.py b/labs/AgentStream/exgentic/src/exgentic/agents/harness/prompts/inject.py new file mode 100644 index 00000000..9b1e237b --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/harness/prompts/inject.py @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +from typing import List, Tuple + +from ..harness_store import HarnessSkill + + +def build_system_message( + system_prompt: str, + memory: str, + retrieved_skills: List[Tuple[HarnessSkill, float]], +) -> str: + parts = [system_prompt] + + if memory and memory.strip(): + parts.append("\n\n## Long-Term Memory\n") + parts.append(memory) + + if retrieved_skills: + parts.append("\n\n## Retrieved Skills\n") + for skill, _score in retrieved_skills: + parts.append(f"### Skill: {skill.name}") + parts.append(f"*{skill.description}*\n") + parts.append(skill.body) + parts.append("") + + return "\n".join(parts) diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/harness/retriever.py b/labs/AgentStream/exgentic/src/exgentic/agents/harness/retriever.py new file mode 100644 index 00000000..2299a2cd --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/harness/retriever.py @@ -0,0 +1,93 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +import logging +import math +from typing import List, Tuple + +from .harness_store import HarnessSkill, HarnessStore + +logger = logging.getLogger(__name__) + +_st_model = None +_st_model_name = None + + +def _get_st_model(model_name: str = "all-MiniLM-L6-v2"): + global _st_model, _st_model_name + if _st_model is None or _st_model_name != model_name: + from sentence_transformers import SentenceTransformer + logger.info("Loading SentenceTransformer model: %s", model_name) + _st_model = SentenceTransformer(model_name) + _st_model_name = model_name + return _st_model + + +def compute_embedding( + text: str, model: str = "all-MiniLM-L6-v2" +) -> List[float]: + try: + st = _get_st_model(model) + vec = st.encode([text])[0] + return vec.tolist() + except Exception as exc: + logger.error("Local embedding failed: %s", exc) + return [] + + +def cosine_similarity(a: List[float], b: List[float]) -> float: + if not a or not b or len(a) != len(b): + return 0.0 + dot = sum(x * y for x, y in zip(a, b)) + norm_a = math.sqrt(sum(x * x for x in a)) + norm_b = math.sqrt(sum(x * x for x in b)) + if norm_a == 0 or norm_b == 0: + return 0.0 + return dot / (norm_a * norm_b) + + +def retrieve_skills( + task_text: str, + store: HarnessStore, + top_k: int = 5, + embedding_model: str = "all-MiniLM-L6-v2", +) -> List[Tuple[HarnessSkill, float]]: + if store.skill_count == 0: + return [] + + query_embedding = compute_embedding(task_text, model=embedding_model) + if not query_embedding: + logger.warning("Failed to compute query embedding, returning no skills") + return [] + + skills = store.list_skills() + embeddings = store.get_embeddings() + for skill in skills: + if skill.name not in embeddings: + emb = compute_embedding(skill.description, model=embedding_model) + if emb: + store.set_embedding(skill.name, emb) + + embeddings = store.get_embeddings() + scored: List[Tuple[HarnessSkill, float]] = [] + for skill in skills: + emb = embeddings.get(skill.name) + if emb: + score = cosine_similarity(query_embedding, emb) + scored.append((skill, score)) + + scored.sort(key=lambda x: x[1], reverse=True) + results = scored[:top_k] + + if results: + logger.info( + "Skill retrieval: query='%s...' → retrieved %d/%d skills: %s", + task_text[:60], len(results), len(skills), + [(s.name, f"{score:.3f}") for s, score in results], + ) + else: + logger.info("Skill retrieval: no skills scored above 0") + + return results diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/litellm_tool_calling/__init__.py b/labs/AgentStream/exgentic/src/exgentic/agents/litellm_tool_calling/__init__.py new file mode 100644 index 00000000..367fec41 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/litellm_tool_calling/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/litellm_tool_calling/instance.py b/labs/AgentStream/exgentic/src/exgentic/agents/litellm_tool_calling/instance.py new file mode 100644 index 00000000..9d44cbb1 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/litellm_tool_calling/instance.py @@ -0,0 +1,486 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import json +import time +from typing import Any, Union + +import litellm +litellm.cache = None # Disable LiteLLM +from litellm import ( + ChatCompletionAssistantMessage, + ChatCompletionDeveloperMessage, + ChatCompletionToolMessage, + ChatCompletionUserMessage, +) + +from ...core.agent_instance import AgentInstance +from ...core.context import get_context +from ...core.types import ( + Action, + ActionType, + Message, + MessageAction, + MessageObservation, + MessagePayload, + ModelSettings, + Observation, + RetryStrategy, +) +from ...integrations.litellm.health import check_model_accessible_sync +from ...utils.cost import LiteLLMCostReport +from ...utils.settings import get_settings +from .utils import ToolCall, ToolsActionsRegistry + +settings = get_settings() + + +class NonRetryableCompletionError(ValueError): + """Raised when a completion response should not be retried.""" + + +class LiteLLMToolCallingAgentInstance(AgentInstance): + """Ultra-simple tool-calling agent. + + - If the model produces tool_calls, convert them directly to Actions without schema verification. + - If the model produces a plain assistant message, interpret it as a `message` action + (even if that tool is not advertised) and emit a corresponding Action. + """ + + def __init__( + self, + session_id: str, + model: str = "gpt-4o-mini", + max_steps: int = 150, + enable_tool_shortlisting: bool = True, + max_selected_tools: int = 30, + model_settings: ModelSettings | None = None, + allow_truncated_messages: bool = False, + ): + super().__init__(session_id) + self.model = model + self.max_steps = max_steps + self.enable_tool_shortlisting = enable_tool_shortlisting + self.max_selected_tools = max_selected_tools + if model_settings is None: + self.model_settings = ModelSettings() + elif isinstance(model_settings, ModelSettings): + self.model_settings = model_settings + else: + raise ValueError("model_settings must be a ModelSettings instance.") + self._allow_truncated_messages = allow_truncated_messages + self._use_cache = settings.litellm_caching + self.logger.debug( + "LiteLLM cache %s (dir=%s)", + "enabled" if self._use_cache else "disabled", + settings.resolved_litellm_cache_dir(), + ) + + self.messages: list[ + Union[ + ChatCompletionAssistantMessage, + ChatCompletionToolMessage, + ChatCompletionUserMessage, + ] + ] = [] + self._step_count = 0 + self._cost_data = LiteLLMCostReport.initialize_empty(model_name=self.model) + + # Check model accessibility + check_model_accessible_sync(self.model, logger=self.logger) + + def start(self, task, context, actions): + """Receive work payload, build tool registry, and seed conversation.""" + super().start(task, context, actions) + + for a in self.actions: + if not isinstance(a, ActionType): + raise ValueError("Invalid action type provided to agent") + + self._all_actions: list[ActionType] = list(self.actions) + self._registry = ToolsActionsRegistry(self._all_actions) + + # Seed conversation with task + context + content_parts: list[Any] = [] + ctx = "" + if self.context: + for k, v in self.context.items(): + if isinstance(v, dict) and v.get("type") == "image_url": + content_parts.append({"type": "image_url", "image_url": {"url": v["data"], "detail": "high"}}) + else: + ctx += f"\n<{k}>\n{v}\n" + + text_content = f"{self.task}\n{ctx}" + if content_parts: + content_parts.insert(0, {"type": "text", "text": text_content}) + self._add_message(ChatCompletionUserMessage(role="user", content=content_parts)) + else: + self._add_message(ChatCompletionUserMessage(role="user", content=text_content)) + + def _register_cost(self, usage: litellm.Usage): + self._cost_data.update_cost_from_tokens(usage.prompt_tokens, usage.completion_tokens) + + def _add_message(self, message): + self.logger.info(f"Adding message to chat history: {message}") + self.messages.append(message) + + def _observe(self, observation: Observation | None): + if observation is None: + self.logger.info("Skipping observation: None") + return + + observations = observation.to_observation_list() + if observation.is_empty(): + # Preserve tool results even when the result payload is empty. + if not any(obs.invoking_actions for obs in observations): + self.logger.info("Skipping observation: empty with no invoking_actions") + return + + for obs in observations: + # Structured user messages: add and move on + if isinstance(obs, MessageObservation) and isinstance(obs.result, MessagePayload): + self._add_message(ChatCompletionUserMessage(role="user", content=obs.result.message)) + continue + + if len(obs.invoking_actions) > 0: + invoking = obs.invoking_actions[0] + if invoking.name == "message": + # Fallback: treat as user-visible content + self._add_message(ChatCompletionUserMessage(role="user", content=str(obs))) + continue + action_id = invoking.id + tool_call_id = invoking.id + if not (isinstance(tool_call_id, str) and tool_call_id.startswith("call_")): + tool_call_id = self._registry.action_id_to_tool_call_id.get(action_id) + if tool_call_id is None: + raise RuntimeError(f"Unable to map tool call id for action {action_id}") + value = obs.result + + # Extract image_url entries for vision support + image_parts: list[dict] = [] + if isinstance(value, dict): + text_value = {} + for k, v in value.items(): + if isinstance(v, dict) and v.get("type") == "image_url": + image_parts.append({"type": "image_url", "image_url": {"url": v["data"], "detail": "high"}}) + else: + text_value[k] = v + if image_parts: + value = text_value # Tool result without image data + + try: + content = json.dumps(value, ensure_ascii=False, separators=(",", ":")) + except TypeError: + content = str(value) + self._add_message(ChatCompletionToolMessage(role="tool", tool_call_id=tool_call_id, content=content)) + + # Add images as follow-up user message for vision models + if image_parts: + image_parts.insert(0, {"type": "text", "text": "Observation screenshot:"}) + self._add_message(ChatCompletionUserMessage(role="user", content=image_parts)) + else: + # Initial observation (no invoking actions) — handle vision content + value = obs.result + image_parts: list[dict] = [] + if isinstance(value, dict): + text_value = {} + for k, v in value.items(): + if isinstance(v, dict) and v.get("type") == "image_url": + image_parts.append({"type": "image_url", "image_url": {"url": v["data"], "detail": "high"}}) + else: + text_value[k] = v + if image_parts: + value = text_value + if image_parts: + try: + text_content = json.dumps(value, ensure_ascii=False, separators=(",", ":")) + except TypeError: + text_content = str(value) + content_parts: list[dict] = [{"type": "text", "text": f"Initial observation: {text_content}"}] + content_parts.extend(image_parts) + self._add_message(ChatCompletionUserMessage(role="user", content=content_parts)) + else: + self._add_message(ChatCompletionUserMessage(role="user", content=str(obs))) + + def _assistant_tools(self) -> list[dict[str, Any]]: + """Returns list of available tools in openai format. + + If the number of available tools is less the max_selected_tools parameter, + returns all available tools. + + Otherwise, calls an LLM to shortlist the tools, and find the most relevant one for + the current stage in the chat, + + """ + tools = self._registry.openai_tools() + + if not self.enable_tool_shortlisting: + self.logger.info( + "Tool shortlisting disabled: returning all %d tools", + len(tools), + ) + return tools + + if len(tools) <= self.max_selected_tools: + self.logger.info( + "Tool shortlist bypassed: %d tools <= max_selected_tools", + len(tools), + ) + return tools + self.logger.info( + "Selecting tools: %d available -> top %d", + len(tools), + self.max_selected_tools, + ) + + names = [tool["function"]["name"] for tool in tools] + + names_str = "" + for tool in tools: + names_str += f"\n- {tool['function']['name']}: {tool['function']['description']}" + + history_text = self._render_history_for_shortlist() + self.logger.info("Tool shortlist history chars: %d", len(history_text)) + + dev = ChatCompletionDeveloperMessage( + role="developer", + content=( + f"Please before providing your next move list the names of the top " + f"{self.max_selected_tools} tools that are somewhat relevant for the next step, " + "ordered by relevancy (most to least). Return ONLY a JSON object with this shape: " + '{\n "tools": ["tool_name_1", "tool_name_2", ...]\n}.\n' + f"Choose from these tools only: {names_str}.\n" + f"Do not call any of those tools just return the list of the top " + f"{self.max_selected_tools} relevant tools names in the required format." + ), + ) + history = ChatCompletionUserMessage( + role="user", + content=f"Conversation so far (plain text):\n{history_text}", + ) + + try: + response = self._completion( + model=self.model, + messages=[dev, history], + caching=self._use_cache, + ) + except Exception as exc: + self.logger.warning("Tool shortlisting LLM call failed: %s", exc) + return tools[: self.max_selected_tools] + + self._register_cost(response.usage) + + text = response.choices[0].message["content"] + + if text is None: + text = str(response.choices[0].message) + + self.logger.info("Tool shortlist model response: %s", text) + + positions = [] + for name in names: + idx = text.find(name) + if idx != -1: + positions.append((idx, name)) + + if len(positions) == 0: + selected_tools = tools[: self.max_selected_tools] + self.logger.info( + "Tool shortlist fallback: %d -> %d (no matches in model response)", + len(tools), + len(selected_tools), + ) + if len(selected_tools) == 0: + self.logger.warning("Tool shortlist reduced to 0 tools") + return selected_tools + + # Sort tools by the order they appear in the model response + positions.sort(key=lambda x: x[0]) + + ordered_tools = [name for _, name in positions] + + selected_names = ordered_tools[: self.max_selected_tools] + name_to_tool = {tool["function"]["name"]: tool for tool in tools} + selected_tools = [name_to_tool[name] for name in selected_names] + self.logger.info( + "Tool shortlist from model: %d -> %d", + len(tools), + len(selected_tools), + ) + if len(selected_tools) == 0: + self.logger.warning("Tool shortlist reduced to 0 tools") + return selected_tools + + def _render_history_for_shortlist(self) -> str: + parts = [] + for message in self.messages: + msg = self._message_to_dict(message) + role = msg.get("role") or "unknown" + if role == "tool": + content = msg.get("content", "") + parts.append(f"tool: {content}") + continue + content = msg.get("content") + if content: + parts.append(f"{role}: {content}") + tool_calls = msg.get("tool_calls") or [] + for tool_call in tool_calls: + function = tool_call.get("function") or {} + name = function.get("name") or tool_call.get("name") + arguments = function.get("arguments") + parts.append(f"{role} tool_call: {name}({arguments})") + return "\n".join(parts) + + @staticmethod + def _message_to_dict(message: Any) -> dict[str, Any]: + if isinstance(message, dict): + return message + if hasattr(message, "model_dump"): + return message.model_dump() + if hasattr(message, "dict"): + return message.dict() + raise TypeError(f"Unsupported message type: {type(message).__name__}") + + def _extract_tool_calls(self, message: litellm.Message) -> list[ToolCall]: + """Extract tool calls from the message object returned from the litellm call.""" + if not message.tool_calls: + return [] + + tool_calls: list[ToolCall] = [] + + for tool_call in message.tool_calls: + tool_calls.append( + { + "name": tool_call.function.name, + "arguments": tool_call.function.arguments, + "id": tool_call.id, + } + ) + + return tool_calls + + def react(self, observation: Observation | None) -> Action | None: + self._step_count += 1 + if self._step_count > self.max_steps: + self.logger.warning("Finished: max steps reached (%d)", self.max_steps) + return None + + self._observe(observation) + + response = self._completion( + model=self.model, + messages=self.messages, + tools=self._assistant_tools(), + caching=self._use_cache, + ) + + self._register_cost(response.usage) + + choice = response["choices"][0] + message = choice["message"] + finish_reason = choice.get("finish_reason") + + if finish_reason == "tool_calls": + tool_calls = self._extract_tool_calls(message) + self._add_message( + ChatCompletionAssistantMessage( + role="assistant", + tool_calls=[ + { + "id": tool_call["id"], + "type": "function", + "function": { + "name": tool_call["name"], + "arguments": tool_call["arguments"], + }, + } + for tool_call in tool_calls + ], + ) + ) + actions = self._registry.tool_calls_to_action(tool_calls) + else: + actions = MessageAction(arguments=Message(content=message.content)) + self._add_message( + ChatCompletionAssistantMessage( + role="assistant", + content=message.content, + ) + ) + + self.logger.info(f"Invoking action: {actions}") + return actions + + def _completion(self, **kwargs): + call_kwargs = self.model_settings.model_dump( + exclude_none=True, + exclude={"num_retries", "retry_after", "retry_strategy"}, + ) + call_kwargs.update(kwargs) + # Use 'metadata' parameter instead of 'litellm_metadata' - LiteLLM passes this to callbacks + call_kwargs.setdefault("metadata", {})["context"] = get_context() + return self._completion_with_retries(call_kwargs) + + def _completion_with_retries(self, call_kwargs: dict[str, Any]): + num_retries = self.model_settings.num_retries or 0 + max_attempts = max(1, num_retries + 1) + for attempt in range(max_attempts): + try: + response = litellm.completion(max_retries=0, **call_kwargs) + self._raise_if_invalid_completion(response) + return response + except NonRetryableCompletionError: + raise + except Exception as exc: + if attempt >= num_retries: + raise + delay = self.model_settings.retry_after + retry_strategy = self.model_settings.retry_strategy.value + if retry_strategy == RetryStrategy.EXPONENTIAL_BACKOFF.value: + delay *= 2**attempt + self.logger.warning( + "LiteLLM completion failed (attempt %d/%d): %s", + attempt + 1, + num_retries + 1, + exc, + ) + if delay > 0: + time.sleep(delay) + return None + + def _raise_if_invalid_completion(self, response: Any) -> None: + try: + choice = response["choices"][0] + message = choice["message"] + finish_reason = choice.get("finish_reason") + except Exception: + return + + if finish_reason == "length" and not self._allow_truncated_messages: + self.logger.error( + "LiteLLM completion truncated (finish_reason=length). Raw response: %s", + response, + ) + raise NonRetryableCompletionError( + "LiteLLM completion truncated (finish_reason=length). " + "To allow truncated responses, configure the agent with " + "allow_truncated_messages=True, or increase max_tokens." + ) + + if finish_reason != "tool_calls": + if message is None or message.content is None: + self.logger.error( + "LiteLLM completion missing assistant content " "(finish_reason=%s). Raw response: %s", + finish_reason, + response, + ) + raise ValueError("LiteLLM completion missing assistant content.") + + def close(self) -> None: + pass + + def get_cost(self) -> LiteLLMCostReport: + return self._cost_data diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/litellm_tool_calling/litellm_tool_calling_agent.py b/labs/AgentStream/exgentic/src/exgentic/agents/litellm_tool_calling/litellm_tool_calling_agent.py new file mode 100644 index 00000000..c6172608 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/litellm_tool_calling/litellm_tool_calling_agent.py @@ -0,0 +1,54 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from typing import Any, ClassVar + +from ...core.agent import Agent +from ...core.types import ModelSettings + + +class LiteLLMToolCallingAgent(Agent): + """Agent factory that always assigns the message-to-user mapping variant.""" + + display_name: ClassVar[str] = "LiteLLM Tool Calling" + slug_name: ClassVar[str] = "tool_calling" + + model: str = "watsonx/openai/gpt-oss-120b" + max_steps: int = 150 + enable_tool_shortlisting: bool = False + max_selected_tools: int = 30 + model_settings: ModelSettings | None = None + allow_truncated_messages: bool = False + + @classmethod + def _get_instance_class(cls): + from .instance import LiteLLMToolCallingAgentInstance + + return LiteLLMToolCallingAgentInstance + + @classmethod + def _get_instance_class_ref(cls) -> str: + return "exgentic.agents.litellm_tool_calling.instance:LiteLLMToolCallingAgentInstance" + + @property + def model_name(self) -> str: # type: ignore[override] + return str(self.model).split("/")[-1] + + def get_models_names(self) -> list[str]: # type: ignore[override] + return [str(self.model)] + + def _get_instance_kwargs( + self, + session_id: str, + ) -> dict[str, Any]: + return { + "session_id": session_id, + "model": self.model, + "enable_tool_shortlisting": self.enable_tool_shortlisting, + "max_selected_tools": self.max_selected_tools, + "max_steps": self.max_steps, + "model_settings": self.model_settings, + "allow_truncated_messages": self.allow_truncated_messages, + } diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/litellm_tool_calling/utils.py b/labs/AgentStream/exgentic/src/exgentic/agents/litellm_tool_calling/utils.py new file mode 100644 index 00000000..03aac91a --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/litellm_tool_calling/utils.py @@ -0,0 +1,135 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import json +from typing import Any, Optional + +from typing_extensions import TypedDict + +from ...core.actions import build_action, build_unknown_action +from ...core.types import ( + Action, + ActionType, + ParallelAction, + SingleAction, + SingleObservation, +) + + +class PartialAction(SingleAction): + arguments: dict + + +class ToolCall(TypedDict): + name: str + arguments: str + id: str + + +def extract_arguments(action_type: ActionType): + return action_type.arguments + + +class ToolsActionsRegistry: + _MAX_SAFE_SCHEMA_INT = 2_147_483_647 + + @classmethod + def _clamp_schema_ints(cls, obj): + if isinstance(obj, dict): + return {k: cls._clamp_schema_ints(v) for k, v in obj.items()} + if isinstance(obj, list): + return [cls._clamp_schema_ints(v) for v in obj] + if isinstance(obj, int) and not isinstance(obj, bool): + if obj > cls._MAX_SAFE_SCHEMA_INT: + return cls._MAX_SAFE_SCHEMA_INT + if isinstance(obj, float): + if obj > cls._MAX_SAFE_SCHEMA_INT: + return float(cls._MAX_SAFE_SCHEMA_INT) + return obj + + @staticmethod + def format_observation(observation: SingleObservation) -> str: + """Serialize observation.result to JSON if possible to satisfy tool message requirements.""" + value = observation.result + try: + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + except TypeError: + return str(value) + + def __init__(self, actions: list[ActionType]): + self.action_types: list[ActionType] = [] + self.name_to_action: dict[str, ActionType] = {} + self.action_id_to_tool_call_id: dict[str, str] = {} + for action in actions: + self.add_action(action) + + def add_action(self, action: ActionType): + if not isinstance(action, ActionType): + raise ValueError("bad action") + self.action_types.append(action) + self.name_to_action[action.name] = action + + def openai_tools(self) -> list[dict[str, Any]]: + tools: list[dict[str, Any]] = [] + for action in self.action_types: + # Skip non-environment messaging actions; agents handle messaging flow + if action.is_message: + continue + arguments_type = extract_arguments(action) + schema = arguments_type.model_json_schema() # type: ignore[attr-defined] + # Bedrock rejects oversized integer values in tool schemas. + schema = self._clamp_schema_ints(schema) + tools.append( + { + "type": "function", + "function": { + "name": action.name, + "description": action.description, + "parameters": schema, + }, + } + ) + tools.sort( + key=lambda tool: ( + tool.get("type", ""), + tool.get("function", {}).get("name", ""), + ) + ) + return tools + + def _tool_call_to_single_action(self, tool_call: ToolCall) -> SingleAction: + name = tool_call["name"] + action_type = self.name_to_action.get(name) + + action_id = tool_call.get("id") + if action_type: + action = build_action(action_type, tool_call["arguments"], action_id=action_id) + else: + action = build_unknown_action(name, tool_call.get("arguments", {}), action_id=action_id) + + if "id" not in tool_call: + tool_call["id"] = action.id + + self.action_id_to_tool_call_id[action.id] = tool_call["id"] + + return action + + def tool_calls_to_action(self, tool_calls: list[ToolCall]) -> Optional[Action]: + actions: list[SingleAction] = [] + for tool_call in tool_calls: + actions.append(self._tool_call_to_single_action(tool_call)) + if len(actions) == 0: + return None + if len(actions) == 1: + return actions[0] + return ParallelAction(actions=actions) + + +def tool_call_to_dict(tool_call): + return { + "function": vars(tool_call.function), + "id": tool_call.id, + "type": tool_call.type, + } diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/openai/__init__.py b/labs/AgentStream/exgentic/src/exgentic/agents/openai/__init__.py new file mode 100644 index 00000000..367fec41 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/openai/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/openai/instance.py b/labs/AgentStream/exgentic/src/exgentic/agents/openai/instance.py new file mode 100644 index 00000000..973f23ee --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/openai/instance.py @@ -0,0 +1,307 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import asyncio +import logging +from typing import Any + +import httpx +from agents import Agent as OpenAIAgent +from agents import Runner +from agents.extensions.models.litellm_model import LitellmModel +from agents.lifecycle import RunHooksBase +from agents.mcp import MCPServerStreamableHttp, MCPServerStreamableHttpParams +from agents.model_settings import ModelSettings as OpenAIModelSettings +from agents.model_settings import Reasoning +from agents.run import RunConfig +from agents.usage import Usage + +from ...adapters.agents.mcp_agent import MCPAgentInstance +from ...core.context import get_context +from ...core.types import ModelSettings, RetryStrategy +from ...integrations.litellm.health import acheck_model_accessible +from ...observers.logging import ( + attach_library_logger_to_handler, + restore_library_logger, +) +from ...utils.cost import UpdatableCostReport, litellm_tokens_cost +from ...utils.settings import get_settings +from ...utils.sync import run_sync +from .openai_mcp_agent import MCPConfig + +settings = get_settings() + + +class _UsageRunHooks(RunHooksBase[dict[str, Any], OpenAIAgent]): + def __init__(self, record_usage) -> None: + super().__init__() + self._record_usage = record_usage + + async def on_llm_end(self, context, agent, response) -> None: + self._record_usage(response.usage) + + +class RetryingLitellmModel(LitellmModel): + def __init__( + self, + model: str, + *, + num_retries: int, + retry_after: float, + retry_strategy: str | RetryStrategy, + ): + super().__init__(model=model) + if isinstance(retry_strategy, RetryStrategy): + retry_strategy = retry_strategy.value + if retry_strategy not in ("exponential_backoff_retry", "constant_retry"): + raise ValueError(f"Unsupported retry_strategy: {retry_strategy}") + if num_retries < 0: + raise ValueError("num_retries must be >= 0") + if retry_after < 0: + raise ValueError("retry_after must be >= 0") + self._num_retries = num_retries + self._retry_after = retry_after + self._retry_strategy = retry_strategy + + async def _fetch_response(self, *args, **kwargs): + # Inject context for OTEL tracing via model_settings.metadata + # The parent class extracts metadata from model_settings and passes it to litellm.acompletion() + # Note: metadata must be Dict[str, str], so we serialize Context fields individually + ctx = get_context() + + # model_settings is the 3rd positional argument (index 2) + if len(args) > 2: + model_settings = args[2] + if model_settings.metadata is None: + model_settings.metadata = {} + + # Serialize Context fields as individual string metadata entries + model_settings.metadata["exgentic_ctx_run_id"] = ctx.run_id + model_settings.metadata["exgentic_ctx_output_dir"] = ctx.output_dir + model_settings.metadata["exgentic_ctx_cache_dir"] = ctx.cache_dir + if ctx.session_id is not None: + model_settings.metadata["exgentic_ctx_session_id"] = ctx.session_id + if ctx.task_id is not None: + model_settings.metadata["exgentic_ctx_task_id"] = ctx.task_id + model_settings.metadata["exgentic_ctx_role"] = ctx.role.value + if ctx.otel_context is not None: + model_settings.metadata["exgentic_ctx_otel_trace_id"] = ctx.otel_context.trace_id + model_settings.metadata["exgentic_ctx_otel_span_id"] = ctx.otel_context.span_id + + for attempt in range(self._num_retries + 1): + try: + return await super()._fetch_response(*args, **kwargs) + except Exception as exc: + if attempt >= self._num_retries: + raise + delay = self._retry_after + if self._retry_strategy == RetryStrategy.EXPONENTIAL_BACKOFF.value: + delay *= 2**attempt + logging.getLogger(__name__).warning( + "OpenAI MCP LiteLLM call failed (attempt %d/%d): %s", + attempt + 1, + self._num_retries + 1, + exc, + ) + if delay > 0: + await asyncio.sleep(delay) + return None + + +class OpenAIMCPAgentInstance(MCPAgentInstance): + """OpenAI Agents SDK + MCP (sync entrypoint, async core).""" + + def __init__( + self, + session_id: str, + model_id: str, + max_steps: int = 150, + model_settings: ModelSettings | None = None, + mcp_config: MCPConfig | dict | None = None, + ): + super().__init__(session_id) + self.model_id = model_id + self.max_steps = max_steps + if model_settings is None: + self.model_settings = ModelSettings() + elif isinstance(model_settings, ModelSettings): + self.model_settings = model_settings + else: + raise ValueError("model_settings must be a ModelSettings instance.") + if mcp_config is None: + self.mcp_config = MCPConfig() + elif isinstance(mcp_config, dict): + self.mcp_config = MCPConfig(**mcp_config) + else: + self.mcp_config = mcp_config + self._total_input_tokens = 0 + self._total_output_tokens = 0 + self._model_access_checked = False + + async def _check_model_access_once(self) -> None: + if self._model_access_checked or self.mcp_config.skip_health_check: + return + self.logger.info("Running LiteLLM model health check (model=%s)", self.model_id) + await acheck_model_accessible(self.model_id) + self._model_access_checked = True + + def _record_usage(self, usage: Usage | None) -> None: + if usage is None: + return + self._total_input_tokens += usage.input_tokens + self._total_output_tokens += usage.output_tokens + + def run_mcp_agent(self, mcp_host: str, mcp_port: int) -> Any: + # Run async core on the shared loop (sync API) + return run_sync(self.run_mcp_agent_async(mcp_host, mcp_port), timeout=600.0) + + async def run_mcp_agent_async(self, mcp_host: str, mcp_port: int) -> Any: + RunConfig.tracing_disabled = True + + prompt = self._build_prompt() + + file_handler = next( + (h for h in self.logger.handlers if isinstance(h, logging.FileHandler)), + None, + ) + logger_states: list[tuple] = [] + + try: + if file_handler: + logger_states += [ + attach_library_logger_to_handler("agents", file_handler), + attach_library_logger_to_handler(__name__, file_handler), + ] + logging.getLogger("agents").setLevel(logging.INFO) + await self._check_model_access_once() + + # Create custom httpx client factory with extended timeout + def httpx_client_factory(headers=None, timeout=None, auth=None): + if ( + self.mcp_config.http_timeout_seconds is None + and self.mcp_config.sse_read_timeout_seconds is None + and self.mcp_config.http_connect_timeout_seconds is None + ): + client_timeout = httpx.Timeout(None) + else: + client_timeout = httpx.Timeout( + self.mcp_config.http_timeout_seconds, + connect=self.mcp_config.http_connect_timeout_seconds, + read=self.mcp_config.sse_read_timeout_seconds, + ) + return httpx.AsyncClient( + headers=headers, + timeout=client_timeout, + auth=auth, + ) + + mcp_params: dict[str, Any] = { + "url": f"http://{mcp_host}:{mcp_port}/mcp", + "httpx_client_factory": httpx_client_factory, + "terminate_on_close": self.mcp_config.terminate_on_close, + } + if self.mcp_config.headers is not None: + mcp_params["headers"] = self.mcp_config.headers + if self.mcp_config.http_timeout_seconds is not None: + mcp_params["timeout"] = self.mcp_config.http_timeout_seconds + if self.mcp_config.sse_read_timeout_seconds is not None: + mcp_params["sse_read_timeout"] = self.mcp_config.sse_read_timeout_seconds + + async with MCPServerStreamableHttp( + params=MCPServerStreamableHttpParams(**mcp_params), + cache_tools_list=self.mcp_config.cache_tools_list, + name=self.mcp_config.name, + client_session_timeout_seconds=self.mcp_config.client_session_timeout_seconds, + use_structured_content=self.mcp_config.use_structured_content, + max_retry_attempts=self.mcp_config.max_retry_attempts, + retry_backoff_seconds_base=self.mcp_config.retry_backoff_seconds_base, + message_handler=self.mcp_config.message_handler, + ) as mcp_server: + temperature = self.model_settings.temperature + reasoning_effort = self.model_settings.reasoning_effort + openai_model_settings = OpenAIModelSettings( + temperature=temperature if temperature is not None else 1.0, + max_tokens=self.model_settings.max_tokens, + top_p=self.model_settings.top_p, + reasoning=(Reasoning(effort=reasoning_effort) if reasoning_effort is not None else None), + ) + num_retries = self.model_settings.num_retries or 0 + retry_after = self.model_settings.retry_after + retry_strategy = self.model_settings.retry_strategy.value + openai_model_settings.extra_args = { + "caching": settings.litellm_caching, + "max_retries": 0 if num_retries > 0 else 5, + } + agent = OpenAIAgent( + name="Assistant", + instructions=prompt, + model=RetryingLitellmModel( + model=self.model_id, + num_retries=num_retries, + retry_after=retry_after, + retry_strategy=retry_strategy, + ), + model_settings=openai_model_settings, + mcp_servers=[mcp_server], + ) + self.logger.info( + "Starting OpenAI MCP agent run (model=%s, task=%s, max_turns=%s)", + self.model_id, + self.task, + self.max_steps, + ) + hooks = _UsageRunHooks(self._record_usage) + try: + result = await Runner.run( + agent, + self.task, + max_turns=self.max_steps, + run_config=RunConfig(tracing_disabled=True), + hooks=hooks, + ) + except Exception: + self.logger.exception("OpenAI MCP agent run failed") + raise + if self._total_input_tokens == 0 and self._total_output_tokens == 0: + for resp in result.raw_responses: + self._record_usage(resp.usage) + self.logger.info("OpenAI MCP agent run finished: %s", result) + return result + finally: + for state in logger_states: + if state: + restore_library_logger(*state) + if file_handler: + file_handler.flush() + + def get_cost(self) -> UpdatableCostReport: + report = UpdatableCostReport.initialize_empty(model_name=self.model_id) + if self._total_input_tokens == 0 and self._total_output_tokens == 0: + return report + + cost = litellm_tokens_cost( + model_name=self.model_id, + input_tokens=self._total_input_tokens, + output_tokens=self._total_output_tokens, + ).total_cost + report.add_cost(cost) + return report + + def _build_prompt(self) -> str: + prompt = "" + if self.context: + prompt += f"Context: {self.context}\n\n" + + prompt += ( + "Complete this task using the available tools. Each tool corresponds to an action " + "you can take in the environment. Do not respond or ask clarification questions " + "unless done through a dedicated tool, and only if such tool exist. " + "Any plain message that is not a tool call will end the run in failure.\n" + ) + + if self.initial_observation is not None and not self.initial_observation.is_empty(): + text = str(self.initial_observation).strip() + if text: + prompt += f"\nFirst Observation: {text}\n" + + return prompt diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/openai/openai_mcp_agent.py b/labs/AgentStream/exgentic/src/exgentic/agents/openai/openai_mcp_agent.py new file mode 100644 index 00000000..de508467 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/openai/openai_mcp_agent.py @@ -0,0 +1,68 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from typing import Any, ClassVar + +from pydantic import BaseModel, ConfigDict + +from ...core.agent import Agent +from ...core.types import ModelSettings + + +class MCPConfig(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + + client_session_timeout_seconds: float | None = None + http_timeout_seconds: float | None = None + sse_read_timeout_seconds: float | None = None + http_connect_timeout_seconds: float | None = None + headers: dict[str, str] | None = None + terminate_on_close: bool = True + max_retry_attempts: int = -1 + retry_backoff_seconds_base: float = 1.0 + cache_tools_list: bool = False + use_structured_content: bool = False + skip_health_check: bool = False + name: str | None = None + message_handler: Any | None = None + + +class OpenAIMCPAgent(Agent): + display_name: ClassVar[str] = "OpenAI Solo" + slug_name: ClassVar[str] = "openai_solo" + + model: str + max_steps: int = 150 + model_settings: ModelSettings | None = None + mcp_config: MCPConfig | dict | None = None + + @classmethod + def _get_instance_class(cls): + from .instance import OpenAIMCPAgentInstance + + return OpenAIMCPAgentInstance + + @classmethod + def _get_instance_class_ref(cls) -> str: + return "exgentic.agents.openai.instance:OpenAIMCPAgentInstance" + + def _get_instance_kwargs( + self, + session_id: str, + ) -> dict[str, Any]: + return { + "session_id": session_id, + "model_id": self.model, + "max_steps": self.max_steps, + "model_settings": self.model_settings, + "mcp_config": self.mcp_config, + } + + @property + def model_name(self) -> str: # type: ignore[override] + return str(self.model).split("/")[-1] + + def get_models_names(self) -> list[str]: # type: ignore[override] + return [str(self.model)] diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/openai/requirements.txt b/labs/AgentStream/exgentic/src/exgentic/agents/openai/requirements.txt new file mode 100644 index 00000000..6220519c --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/openai/requirements.txt @@ -0,0 +1 @@ +openai-agents[litellm] diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/reasoning_bank/__init__.py b/labs/AgentStream/exgentic/src/exgentic/agents/reasoning_bank/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/reasoning_bank/evaluator.py b/labs/AgentStream/exgentic/src/exgentic/agents/reasoning_bank/evaluator.py new file mode 100644 index 00000000..b49d1c1a --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/reasoning_bank/evaluator.py @@ -0,0 +1,72 @@ +# Copyright 2026 The Google Research Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Modifications Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + + +class TrajectoryEvaluator: + + def __init__(self, llm_call_fn): + self._llm_call = llm_call_fn + + def evaluate( + self, + intent: str, + think_list: list[str], + action_list: list[str], + observation_list: list[str], + final_response: str = "", + ) -> dict[str, str]: + from .prompts.eval_prompts import build_text_eval_prompt, extract_content + + action_history = "" + for idx, act in enumerate(action_list): + think = think_list[idx] if idx < len(think_list) else "" + if think: + action_history += f"{idx+1}: {think}\n {act}\n" + else: + action_history += f"{idx+1}: {act}\n" + + last_obs = observation_list[-5:] if len(observation_list) >= 5 else observation_list + combined_obs = "\n\n---\n\n".join( + f"[Page state {i+1}/{len(last_obs)}]\n{c}" + for i, c in enumerate(last_obs) + ) + + MAX_OBS_CHARS = 40000 + if len(combined_obs) > MAX_OBS_CHARS: + combined_obs = combined_obs[:MAX_OBS_CHARS] + + prompt, sys_msg = build_text_eval_prompt( + combined_obs, intent, final_response, action_history + ) + + msg_str = self._llm_call(prompt, sys_msg) + + thoughts = extract_content(msg_str, "Thoughts:") + status_raw = extract_content(msg_str, "Status:").replace('"', "").strip().lower() + + + if "success" in status_raw: + status = "success" + else: + status = "failure" + + return {"thoughts": thoughts, "status": status} diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/reasoning_bank/induce_memory.py b/labs/AgentStream/exgentic/src/exgentic/agents/reasoning_bank/induce_memory.py new file mode 100644 index 00000000..3a0a94b2 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/reasoning_bank/induce_memory.py @@ -0,0 +1,70 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Modifications Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +import logging +from typing import Callable + +logger = logging.getLogger(__name__) + + +def format_trajectory(think_list: list[str], action_list: list[str], observation_list: list[str] | None = None) -> str: + trajectory = [] + obs_list = observation_list or [] + for i, (t, a) in enumerate(zip(think_list, action_list)): + if t: + trajectory.append(f"\n{t}\n\n\n{a}\n") + else: + obs = obs_list[i] if i < len(obs_list) else "" + if obs: + trajectory.append(f"\n{obs}\n\n\n{a}\n") + else: + trajectory.append(f"\n{a}\n") + return "\n\n".join(trajectory) + + +def induce_memory( + query: str, + think_list: list[str], + action_list: list[str], + status: str, + eval_thoughts: str, + llm_call_fn: Callable[[str, str], str], + observation_list: list[str] | None = None, +) -> list[str]: + from .prompts.memory_instruction import FAILED_SI, SUCCESSFUL_SI + + trajectory = format_trajectory(think_list, action_list, observation_list) + trajectory = f"**Query:** {query}\n\n**Trajectory:**\n{trajectory}" + + if eval_thoughts: + status_label = "succeeded" if status == "success" else "failed" + trajectory += f"\n\nThe task {status_label} because: {eval_thoughts}" + + if status == "success": + generated_text = llm_call_fn(trajectory, SUCCESSFUL_SI) + else: + generated_text = llm_call_fn(trajectory, FAILED_SI) + + memory_items = [item.strip() for item in generated_text.split("\n\n") if item.strip()] + + logger.info( + "ReasoningBank memory induction: status=%s, generated %d items", + status, len(memory_items), + ) + + return memory_items diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/reasoning_bank/memory_management.py b/labs/AgentStream/exgentic/src/exgentic/agents/reasoning_bank/memory_management.py new file mode 100644 index 00000000..1a622551 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/reasoning_bank/memory_management.py @@ -0,0 +1,112 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Modifications Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +import numpy as np +from sentence_transformers import SentenceTransformer + +if TYPE_CHECKING: + from .rb_store import MemoryEntry, ReasoningBankStore + +logger = logging.getLogger(__name__) + +RETRIEVAL_INSTRUCTION = ( + "Given the prior web navigation queries, your task is to analyze a current " + "query's intent and select relevant prior queries that could help resolve it." +) + +_st_model = None +_st_model_name = None + + +def _get_st_model(model_name: str = "all-MiniLM-L6-v2") -> SentenceTransformer: + global _st_model, _st_model_name + if _st_model is None or _st_model_name != model_name: + logger.info("Loading SentenceTransformer model: %s", model_name) + _st_model = SentenceTransformer(model_name) + _st_model_name = model_name + return _st_model + + +def get_detailed_instruct(task_description: str, query: str) -> str: + return f"Instruct: {task_description}\nQuery: {query}" + + +def l2_normalize(x: np.ndarray, axis: int = -1) -> np.ndarray: + norm = np.linalg.norm(x, axis=axis, keepdims=True) + norm = np.where(norm == 0, 1.0, norm) + return x / norm + + +def compute_embedding(text: str, model: str = "all-MiniLM-L6-v2") -> list[float]: + st = _get_st_model(model) + vec = st.encode([text])[0] + return vec.tolist() + + +def select_memory( + store: "ReasoningBankStore", + cur_query: str, + embedding_model: str, + top_k: int = 1, + exclude_task_id: str | None = None, +) -> list["MemoryEntry"]: + + cache_emb = store.get_embeddings_array() + if cache_emb is None or len(cache_emb) == 0: + logger.info("ReasoningBank retrieval: no cached embeddings, returning empty.") + return [] + + entries = store.get_entries() + entry_ids = store.get_entry_ids() + + instruction_query = get_detailed_instruct(RETRIEVAL_INSTRUCTION, cur_query) + instruct_vec = np.array( + compute_embedding(instruction_query, embedding_model), + dtype=np.float32, + ).reshape(1, -1) + + instruct_vec = l2_normalize(instruct_vec, axis=1) + cache_emb_norm = l2_normalize(cache_emb, axis=1) + + scores = (instruct_vec @ cache_emb_norm.T).squeeze(0) * 100.0 # (N,) + + id_score_pairs = [] + for i, (eid, score) in enumerate(zip(entry_ids, scores)): + if exclude_task_id and eid == exclude_task_id: + continue + id_score_pairs.append((i, float(score))) + + id_score_pairs.sort(key=lambda x: x[1], reverse=True) + + top_entries = [] + for idx, _score in id_score_pairs[:top_k]: + top_entries.append(entries[idx]) + + return top_entries + + +def format_memories_for_prompt(entries: list["MemoryEntry"]) -> str: + mem_items = [] + for entry in entries: + for item in entry.memory_items: + if item.strip(): + mem_items.append(item.strip()) + return "\n\n".join(mem_items) diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/reasoning_bank/prompts/__init__.py b/labs/AgentStream/exgentic/src/exgentic/agents/reasoning_bank/prompts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/reasoning_bank/prompts/eval_prompts.py b/labs/AgentStream/exgentic/src/exgentic/agents/reasoning_bank/prompts/eval_prompts.py new file mode 100644 index 00000000..ca4952cd --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/reasoning_bank/prompts/eval_prompts.py @@ -0,0 +1,55 @@ +# Copyright 2026 The Google Research Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Modifications Copyright (C) 2026, The AgentStream organization and its contributors. + + +def extract_content(text: str, start_tag: str) -> str: + for line in text.split("\n"): + if line.startswith(start_tag): + return line[len(start_tag):].strip() + return "" + + +def build_text_eval_prompt( + cap: str, intent: str, response: str, last_actions: str +) -> tuple[str, str]: + system_msg = """You are an expert in evaluating the performance of a task-solving agent. The agent is designed to help a human user complete a task by taking actions in an environment. Given the user's intent, the agent's action history, the environment's feedback, and the agent's response to the user, your goal is to decide whether the agent's execution is successful or not. + +*Strictness rules* +Before calling a task successful, verify all three: +- Completeness: every constraint in the intent is satisfied. +- Grounding: every value or result the agent reports is traceable to a specific observation from the environment; values that were inferred, guessed, or summarized without a visible source count as failures. +- Right target: when the task names a specific entity, confirm the agent acted on that exact entity and not an adjacent one. +When uncertain on any of these, mark failure. A false success is more harmful than a false failure, because memory induction amplifies it into future behavior. + +*IMPORTANT* +Format your response into two lines as shown below: + +Thoughts: " +Status: "success" or "failure" +""" + prompt = f"""User Intent: {intent} + +Action History: +{last_actions} + +Environment feedback (last observations): + +``` +{cap} +``` + +Agent response to the user: {response if response else "N/A"}.""" + return prompt, system_msg diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/reasoning_bank/prompts/memory_instruction.py b/labs/AgentStream/exgentic/src/exgentic/agents/reasoning_bank/prompts/memory_instruction.py new file mode 100644 index 00000000..5b54a634 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/reasoning_bank/prompts/memory_instruction.py @@ -0,0 +1,70 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Modifications Copyright (C) 2026, The AgentStream organization and its contributors. + +SUCCESSFUL_SI = """ +You are an expert at analyzing agent task execution. You will be given a user query, the corresponding trajectory that represents **how an agent successfully accomplished the task**. + +## Guidelines +You need to extract and summarize useful insights in the format of memory items based on the agent's successful trajectory. +The goal of summarized memory items is to be helpful and generalizable for future similar tasks. + +## Important notes + - You must first think why the trajectory is successful, and then summarize the insights. + - You can extract *at most 3* memory items from the trajectory. + - You must not repeat similar or overlapping items. + - Prefer concrete, actionable procedures over abstract principles. Do not embed specific product names, queries, or literal string contents from the task. + +## Output Format +Your output must strictly follow the Markdown format shown below: + +``` +# Memory Item i +## Title +## Description +## Content <1-3 sentences describing the insights learned to successfully accomplishing similar tasks in the future> +``` +""" + +FAILED_SI = """ +You are an expert at analyzing agent task execution. You will be given a user query, the corresponding trajectory that represents **how an agent attempted to resolve the task but failed**. + +## Guidelines +You need to extract and summarize useful insights in the format of memory items based on the agent's failed trajectory. +The goal of summarized memory items is to be helpful and generalizable for future similar tasks. + +## Important notes + - You must first reflect and think why the trajectory failed, and then summarize what lessons you have learned or strategies to prevent the failure in the future. + - You can extract *at most 3* memory items from the trajectory. + - You must not repeat similar or overlapping items. + - Prefer concrete, actionable recovery procedures over abstract principles. Do not embed specific product names, queries, or literal string contents from the task. + +## Output Format +Your output must strictly follow the Markdown format shown below: + +``` +# Memory Item i +## Title +## Description +## Content <1-3 sentences describing the insights learned to avoid such failures and successfully accomplishing similar tasks in the future> +``` +""" + +MEMORY_INJECTION_INSTRUCTION = ( + "Below are some memory items that I accumulated from past interaction from " + "the environment that may be helpful to solve the task. You can use it when " + "you feel it's relevant. In each step, please first explicitly discuss if " + "you want to use each memory item or not, and then take action." +) diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/reasoning_bank/rb_agent.py b/labs/AgentStream/exgentic/src/exgentic/agents/reasoning_bank/rb_agent.py new file mode 100644 index 00000000..ca3c1e15 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/reasoning_bank/rb_agent.py @@ -0,0 +1,77 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +from typing import Any, ClassVar, Optional + +from pydantic import ConfigDict + +from ...core.agent import Agent +from ...core.types import ModelSettings +from ...utils.settings import RunnerName + + +class ReasoningBankAgent(Agent): + + display_name: ClassVar[str] = "ReasoningBank Agent" + slug_name: ClassVar[str] = "reasoning_bank" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + model: str = "gpt-4o" + memory_model: Optional[str] = None + eval_model: Optional[str] = None + embedding_model: str = "all-MiniLM-L6-v2" + + top_k_memories: int = 1 + max_memory_items: int = 3 + + shuffle_mode: str = "isolated" + + benchmark_id: Optional[str] = None + + enable_tool_shortlisting: bool = False + max_selected_tools: int = 30 + + runner: RunnerName | None = None + model_settings: ModelSettings | None = None + + @classmethod + def _get_instance_class(cls): + from .rb_instance import ReasoningBankAgentInstance + return ReasoningBankAgentInstance + + @classmethod + def _get_instance_class_ref(cls) -> str: + return "exgentic.agents.reasoning_bank.rb_instance:ReasoningBankAgentInstance" + + def _get_instance_kwargs(self, session_id: str) -> dict[str, Any]: + return { + "session_id": session_id, + "model": self.model, + "memory_model": self.memory_model or self.model, + "eval_model": self.eval_model or self.model, + "embedding_model": self.embedding_model, + "top_k_memories": self.top_k_memories, + "max_memory_items": self.max_memory_items, + "shuffle_mode": self.shuffle_mode, + "model_settings": self.model_settings, + "benchmark_id": self.benchmark_id, + "enable_tool_shortlisting": self.enable_tool_shortlisting, + "max_selected_tools": self.max_selected_tools, + } + + @property + def model_name(self) -> str: + return str(self.model).split("/")[-1] + + def get_models_names(self) -> list[str]: + names = [str(self.model)] + mm = self.memory_model or self.model + if mm != self.model: + names.append(str(mm)) + em = self.eval_model or self.model + if em != self.model and em != mm: + names.append(str(em)) + return names diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/reasoning_bank/rb_instance.py b/labs/AgentStream/exgentic/src/exgentic/agents/reasoning_bank/rb_instance.py new file mode 100644 index 00000000..523a4f4a --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/reasoning_bank/rb_instance.py @@ -0,0 +1,552 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +import json +import logging +import time +from typing import Any, Dict, Optional, Union + +import litellm +from litellm import ( + ChatCompletionAssistantMessage, + ChatCompletionSystemMessage, + ChatCompletionToolMessage, + ChatCompletionUserMessage, +) + +from ...core.agent_instance import AgentInstance +from ...core.types import ( + Action, + ActionType, + Message, + MessageAction, + MessageObservation, + MessagePayload, + ModelSettings, + Observation, +) +from ...utils.cost import LiteLLMCostReport +from ...utils.settings import get_settings + +from .rb_store import MemoryEntry, ReasoningBankStore +from .memory_management import ( + compute_embedding, + format_memories_for_prompt, + select_memory, +) +from .prompts.memory_instruction import MEMORY_INJECTION_INSTRUCTION +from ..tool_shortlisting import shortlist_tools + +try: + from ...agents.litellm_tool_calling.utils import ToolCall, ToolsActionsRegistry +except ImportError: + ToolsActionsRegistry = None + ToolCall = dict + +settings = get_settings() +logger = logging.getLogger(__name__) + + +class ReasoningBankAgentInstance(AgentInstance): + + def __init__( + self, + session_id: str, + model: str = "gpt-4o", + memory_model: str = "gpt-4o", + eval_model: str = "gpt-4o", + embedding_model: str = "all-MiniLM-L6-v2", + top_k_memories: int = 1, + max_memory_items: int = 3, + shuffle_mode: str = "isolated", + model_settings: Optional[ModelSettings] = None, + benchmark_id: Optional[str] = None, + enable_tool_shortlisting: bool = False, + max_selected_tools: int = 30, + ) -> None: + super().__init__(session_id) + + self.model = model + self.memory_model = memory_model + self.eval_model = eval_model + self.embedding_model = embedding_model + self.top_k_memories = top_k_memories + self.max_memory_items = max_memory_items + self.shuffle_mode = shuffle_mode + self.benchmark_id = benchmark_id + self.enable_tool_shortlisting = enable_tool_shortlisting + self.max_selected_tools = max_selected_tools + + if model_settings is None: + self._model_settings = ModelSettings() + elif isinstance(model_settings, ModelSettings): + self._model_settings = model_settings + else: + self._model_settings = ModelSettings() + + self._cost = LiteLLMCostReport.initialize_empty(model_name=self.model) + self._store: Optional[ReasoningBankStore] = None + + self.messages: list[ + Union[ + ChatCompletionAssistantMessage, + ChatCompletionToolMessage, + ChatCompletionUserMessage, + ChatCompletionSystemMessage, + ] + ] = [] + + self._registry: Optional[Any] = None + self._all_actions: list[ActionType] = [] + self._step_count: int = 0 + + self._think_list: list[str] = [] + self._action_list: list[str] = [] + self._observation_log: list[str] = [] + self._action_log: list[Dict[str, Any]] = [] + + self._query_embedding: Optional[list[float]] = None + self._task_query: str = "" + + def start(self, task: str, context: Dict[str, Any], actions: list[ActionType]) -> None: + super().start(task, context, actions) + + self._all_actions = list(self.actions) + if ToolsActionsRegistry is not None: + self._registry = ToolsActionsRegistry(self._all_actions) + else: + self._registry = None + + self._store = ReasoningBankStore.get_or_create( + shuffle_mode=self.shuffle_mode, + benchmark_id=self.benchmark_id, + ) + self._store.increment_session() + + self._task_query = task + if context: + context_str = "" + for k, v in context.items(): + if isinstance(v, dict) and v.get("type") == "image_url": + continue + context_str += f"\n{k}: {v}" + self._task_query = f"{task}{context_str}" + + try: + self._query_embedding = compute_embedding( + self._task_query, self.embedding_model + ) + except Exception as e: + logger.warning("Failed to compute query embedding: %s", e) + self._query_embedding = None + + retrieved_memories: list[MemoryEntry] = [] + if self._query_embedding is not None and self._store.entry_count > 0: + try: + retrieved_memories = select_memory( + store=self._store, + cur_query=self._task_query, + embedding_model=self.embedding_model, + top_k=self.top_k_memories, + ) + except Exception as e: + logger.warning("Memory retrieval failed: %s", e) + + sys_prompt = "You are an expert agent that completes tasks using available tools." + if retrieved_memories: + memory_text = format_memories_for_prompt(retrieved_memories) + if memory_text.strip(): + sys_prompt += "\n\n" + MEMORY_INJECTION_INSTRUCTION + sys_prompt += "\n\n" + memory_text + + self.messages = [ + ChatCompletionSystemMessage(role="system", content=sys_prompt), + ] + + content_parts: list[Any] = [] + ctx_str = "" + if self.context: + for k, v in self.context.items(): + if isinstance(v, dict) and v.get("type") == "image_url": + content_parts.append({"type": "image_url", "image_url": {"url": v["data"], "detail": "high"}}) + else: + ctx_str += f"\n<{k}>\n{v}\n" + + text_content = f"{self.task}\n{ctx_str}" + if content_parts: + content_parts.insert(0, {"type": "text", "text": text_content}) + self.messages.append(ChatCompletionUserMessage(role="user", content=content_parts)) + else: + self.messages.append( + ChatCompletionUserMessage(role="user", content=text_content) + ) + + logger.info( + "ReasoningBank start: retrieved %d memories for task (store has %d entries)", + len(retrieved_memories), self._store.entry_count, + ) + + def react(self, observation: Optional[Observation]) -> Optional[Action]: + self._step_count += 1 + + self._observe(observation) + + tools = self._assistant_tools() + + response = self._completion( + model=self.model, + messages=self.messages, + tools=tools if tools else None, + ) + if response is None: + return None + + if response.usage: + self._cost.update_cost_from_tokens( + response.usage.prompt_tokens, + response.usage.completion_tokens, + ) + + choice = response["choices"][0] + message = choice["message"] + finish_reason = choice.get("finish_reason") + + if finish_reason == "tool_calls" and self._registry is not None: + tool_calls = self._extract_tool_calls(message) + self.messages.append( + ChatCompletionAssistantMessage( + role="assistant", + tool_calls=[ + { + "id": tc["id"], + "type": "function", + "function": { + "name": tc["name"], + "arguments": tc["arguments"], + }, + } + for tc in tool_calls + ], + ) + ) + + think = message.content if message.content else "" + self._think_list.append(think) + for tc in tool_calls: + self._action_list.append(f"{tc['name']}({tc['arguments']})") + self._action_log.append({ + "step": self._step_count, + "action": tc["name"], + "arguments": tc["arguments"], + }) + + actions = self._registry.tool_calls_to_action(tool_calls) + return actions + else: + content = message.content if message.content else "" + + if not content: + logger.warning( + "ReasoningBank step %d: empty content response (finish_reason=%s), " + "treating as agent inability to continue", + self._step_count, finish_reason, + ) + return None + + self.messages.append( + ChatCompletionAssistantMessage(role="assistant", content=content) + ) + + self._think_list.append(content) + self._action_list.append(f"send_msg_to_user('{content[:200]}')") + + return MessageAction(arguments=Message(content=content)) + + def close(self) -> None: + if not self._store: + return + + if not self._action_list: + logger.info("ReasoningBank close: no actions recorded, skipping memory induction.") + self._save_session_artifacts() + return + + if self._query_embedding is None: + logger.info("ReasoningBank close: embedding unavailable, skipping memory induction.") + self._save_session_artifacts() + return + + try: + self._run_post_session_learning() + except Exception as e: + logger.warning("ReasoningBank close: memory induction failed: %s", e) + + self._save_session_artifacts() + + def _save_session_artifacts(self) -> None: + if not self._store: + return + try: + cp = str(self.paths.agent_dir / "memory_checkpoint.json") + self._store.save_checkpoint(cp) + mt = str(self.paths.agent_dir / "memories.txt") + self._store.save_memories_text(mt) + except Exception as exc: + logger.warning("ReasoningBank: failed to save session artifacts: %s", exc) + + def get_cost(self) -> LiteLLMCostReport: + return self._cost + + def _run_post_session_learning(self) -> None: + from .evaluator import TrajectoryEvaluator + from .induce_memory import induce_memory + + evaluator = TrajectoryEvaluator( + llm_call_fn=lambda prompt, sys_msg: self._llm_call_simple( + self.eval_model, prompt, system_msg=sys_msg + ) + ) + + final_response = "" + for act in reversed(self._action_list): + if "send_msg_to_user" in act: + try: + final_response = act[act.index("(") + 1:act.rindex(")")] + final_response = final_response.strip("'\"") + except (ValueError, IndexError): + pass + break + + eval_result = evaluator.evaluate( + intent=self.task or self._task_query, + think_list=self._think_list, + action_list=self._action_list, + observation_list=self._observation_log, + final_response=final_response, + ) + + status = "success" if eval_result["status"] == "success" else "fail" + eval_thoughts = eval_result.get("thoughts", "") + + logger.info( + "ReasoningBank eval: status=%s, thoughts=%s", + status, eval_thoughts[:100], + ) + + memory_items = induce_memory( + query=self.task or self._task_query, + think_list=self._think_list, + action_list=self._action_list, + status=status, + eval_thoughts=eval_thoughts, + llm_call_fn=lambda user_msg, sys_msg: self._llm_call_simple( + self.memory_model, user_msg, system_msg=sys_msg + ), + observation_list=self._observation_log, + ) + + if memory_items and self._query_embedding is not None: + entry = MemoryEntry( + task_id=self.session_id, + query=self.task or self._task_query, + think_list=self._think_list, + action_list=self._action_list, + status=status, + memory_items=memory_items, + template_id=self.context.get("template_id") if self.context else None, + ) + self._store.add_entry(entry, self._query_embedding) + logger.info( + "ReasoningBank: stored %d memory items (store now has %d entries)", + len(memory_items), self._store.entry_count, + ) + + def _completion(self, **kwargs) -> Any: + """Standard completion with tool support and retry logic.""" + call_kwargs = self._model_settings.model_dump( + exclude_none=True, + exclude={"num_retries", "retry_after", "retry_strategy"}, + ) + call_kwargs.update(kwargs) + if call_kwargs.get("tools") is None: + call_kwargs.pop("tools", None) + + max_attempts = 3 + for attempt in range(max_attempts): + try: + response = litellm.completion(**call_kwargs) + choice = response["choices"][0] if response.get("choices") else None + if choice: + msg = choice.get("message") or {} + has_content = bool(msg.get("content")) + has_tools = bool(msg.get("tool_calls")) + if not has_content and not has_tools: + if attempt + 1 < max_attempts: + logger.warning( + "ReasoningBank LLM call attempt %d/%d: empty response " + "(finish_reason=%s), retrying...", + attempt + 1, max_attempts, + choice.get("finish_reason"), + ) + time.sleep(2 ** attempt) + continue + return response + except Exception as exc: + logger.warning( + "ReasoningBank LLM call attempt %d/%d failed: %s", + attempt + 1, max_attempts, exc, + ) + if attempt + 1 >= max_attempts: + raise + time.sleep(2 ** attempt) + return None + + def _llm_call_simple( + self, + model: str, + prompt: str, + *, + system_msg: str = "", + json_mode: bool = False, + ) -> str: + messages: list = [] + if system_msg: + messages.append({"role": "system", "content": system_msg}) + messages.append({"role": "user", "content": prompt}) + + kwargs: Dict[str, Any] = { + "model": model, + "messages": messages, + "temperature": 1.0, + } + if json_mode: + kwargs["response_format"] = {"type": "json_object"} + + max_attempts = 3 + for attempt in range(max_attempts): + try: + resp = litellm.completion(**kwargs) + if resp.usage: + self._cost.update_cost_from_tokens( + resp.usage.prompt_tokens, + resp.usage.completion_tokens, + ) + return resp.choices[0].message.content or "" + except Exception as exc: + if attempt + 1 >= max_attempts: + logger.error("Simple LLM call failed: %s", exc) + return "" + time.sleep(2 ** attempt) + return "" + + def _observe(self, observation: Optional[Observation]) -> None: + if observation is None: + return + + observations = observation.to_observation_list() + if observation.is_empty(): + if not any(obs.invoking_actions for obs in observations): + return + + for obs in observations: + if isinstance(obs, MessageObservation) and isinstance( + obs.result, MessagePayload + ): + self.messages.append( + ChatCompletionUserMessage(role="user", content=obs.result.message) + ) + self._observation_log.append(obs.result.message) + continue + + if len(obs.invoking_actions) > 0: + invoking = obs.invoking_actions[0] + if invoking.name == "message": + text = str(obs) + self.messages.append( + ChatCompletionUserMessage(role="user", content=text) + ) + self._observation_log.append(text) + continue + + tool_call_id = invoking.id + if not ( + isinstance(tool_call_id, str) + and tool_call_id.startswith("call_") + ): + if self._registry is not None: + tool_call_id = ( + self._registry.action_id_to_tool_call_id.get( + tool_call_id, tool_call_id + ) + ) + + value = obs.result + try: + content = json.dumps(value, ensure_ascii=False, separators=(",", ":")) + except TypeError: + content = str(value) + + if tool_call_id is not None: + self.messages.append( + ChatCompletionToolMessage( + role="tool", + tool_call_id=tool_call_id, + content=content, + ) + ) + else: + self.messages.append( + ChatCompletionUserMessage( + role="user", + content=f"Tool result: {content}", + ) + ) + self._observation_log.append(content) + else: + content = str(obs.result) if hasattr(obs, "result") else str(obs) + self.messages.append( + ChatCompletionUserMessage(role="user", content=content) + ) + self._observation_log.append(content) + + + def _assistant_tools(self) -> list | None: + if self._registry is None: + return None + tools = self._registry.openai_tools() + if not tools: + return None + + if not self.enable_tool_shortlisting: + return tools + + def _cost_cb(usage): + if usage: + self._cost.update_cost_from_tokens( + usage.prompt_tokens, usage.completion_tokens + ) + + return shortlist_tools( + tools=tools, + max_selected=self.max_selected_tools, + messages=self.messages, + completion_fn=self._completion, + model=self.model, + logger=logger, + cost_callback=_cost_cb, + ) + + @staticmethod + def _extract_tool_calls(message: Any) -> list[dict[str, str]]: + if not hasattr(message, "tool_calls") or not message.tool_calls: + return [] + tool_calls = [] + for tc in message.tool_calls: + tool_calls.append({ + "name": tc.function.name, + "arguments": tc.function.arguments, + "id": tc.id, + }) + return tool_calls diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/reasoning_bank/rb_store.py b/labs/AgentStream/exgentic/src/exgentic/agents/reasoning_bank/rb_store.py new file mode 100644 index 00000000..0d8c4ad3 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/reasoning_bank/rb_store.py @@ -0,0 +1,164 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +import json +import logging +import threading +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, ClassVar + +import numpy as np + +logger = logging.getLogger(__name__) + + +@dataclass +class MemoryEntry: + + task_id: str + query: str + think_list: list[str] + action_list: list[str] + status: str + memory_items: list[str] + template_id: str | None = None + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> "MemoryEntry": + return cls( + task_id=d["task_id"], + query=d["query"], + think_list=d.get("think_list", []), + action_list=d.get("action_list", []), + status=d["status"], + memory_items=d.get("memory_items", []), + template_id=d.get("template_id"), + ) + + +class ReasoningBankStore: + + _instances: ClassVar[dict[str, "ReasoningBankStore"]] = {} + _class_lock: ClassVar[threading.Lock] = threading.Lock() + + def __init__(self, store_id: str) -> None: + self._store_id = store_id + self._lock = threading.Lock() + self._entries: list[MemoryEntry] = [] + self._embeddings: list[list[float]] = [] + self._session_count: int = 0 + + @classmethod + def get_or_create( + cls, + shuffle_mode: str = "isolated", + benchmark_id: str | None = None, + ) -> "ReasoningBankStore": + if shuffle_mode == "isolated": + store_id = f"rb_isolated_{benchmark_id or 'default'}" + elif shuffle_mode == "sequential": + store_id = "rb_sequential_global" + elif shuffle_mode == "interleaved": + store_id = "rb_interleaved_global" + else: + store_id = f"rb_{shuffle_mode}_{benchmark_id or 'default'}" + + with cls._class_lock: + if store_id not in cls._instances: + cls._instances[store_id] = cls(store_id) + return cls._instances[store_id] + + @classmethod + def list_stores(cls) -> dict[str, "ReasoningBankStore"]: + with cls._class_lock: + return dict(cls._instances) + + @classmethod + def reset_all(cls) -> None: + with cls._class_lock: + cls._instances.clear() + + @property + def store_id(self) -> str: + return self._store_id + + @property + def session_count(self) -> int: + with self._lock: + return self._session_count + + @property + def entry_count(self) -> int: + with self._lock: + return len(self._entries) + + def increment_session(self) -> int: + with self._lock: + self._session_count += 1 + return self._session_count + + def add_entry(self, entry: MemoryEntry, embedding: list[float]) -> None: + with self._lock: + self._entries.append(entry) + self._embeddings.append(embedding) + + def get_entries(self) -> list[MemoryEntry]: + with self._lock: + return list(self._entries) + + def get_embeddings_array(self) -> np.ndarray | None: + with self._lock: + if not self._embeddings: + return None + return np.array(self._embeddings, dtype=np.float32) + + def get_entry_ids(self) -> list[str]: + with self._lock: + return [e.task_id for e in self._entries] + + def save_checkpoint(self, path: str) -> None: + with self._lock: + data = { + "store_id": self._store_id, + "session_count": self._session_count, + "entries": [e.to_dict() for e in self._entries], + "embeddings": self._embeddings, + } + Path(path).parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False) + logger.info("ReasoningBankStore[%s]: saved checkpoint (%d entries) to %s", + self._store_id, len(self._entries), path) + + def save_memories_text(self, path: str) -> None: + Path(path).parent.mkdir(parents=True, exist_ok=True) + with self._lock: + lines = [ + f"# ReasoningBank Memory Store: {self._store_id}", + f"# Entries: {len(self._entries)}", + f"# Sessions: {self._session_count}", + "", + ] + for i, entry in enumerate(self._entries): + lines.append(f"--- Entry {i + 1} [{entry.task_id[:20]}] status={entry.status} ---") + for item in entry.memory_items: + lines.append(item.strip()) + lines.append("") + with open(path, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) + + def load_checkpoint(self, path: str) -> None: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + with self._lock: + self._session_count = data.get("session_count", 0) + self._entries = [MemoryEntry.from_dict(d) for d in data.get("entries", [])] + self._embeddings = data.get("embeddings", []) + logger.info("ReasoningBankStore[%s]: loaded checkpoint (%d entries) from %s", + self._store_id, len(self._entries), path) diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/replay/__init__.py b/labs/AgentStream/exgentic/src/exgentic/agents/replay/__init__.py new file mode 100644 index 00000000..367fec41 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/replay/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/replay/replay_agent.py b/labs/AgentStream/exgentic/src/exgentic/agents/replay/replay_agent.py new file mode 100644 index 00000000..ab8d5797 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/replay/replay_agent.py @@ -0,0 +1,107 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""ReplayAgent — replays a recorded trajectory against a benchmark. + +Usage:: + + evaluate(benchmark="gsm8k", agent="replay", agent_kwargs={"recording": "path/to/recording"}) + +A *recording* is a directory containing: + trajectory.jsonl — the recorded action/observation events + session.json — session manifest (task, context, actions schema) + +These files are produced automatically by ``exgentic evaluate`` (under +``outputs//sessions//``). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, ClassVar + +from ...core.agent import Agent +from ...core.agent_instance import AgentInstance +from ...core.types import Action, Observation + + +class ReplayAgentInstance(AgentInstance): + """Replays recorded actions from a trajectory file.""" + + def __init__( + self, + *, + session_id: str, + trajectory_path: Path, + ) -> None: + super().__init__(session_id=session_id) + self._actions = self._load_actions(trajectory_path) + self._step = 0 + + def start(self, task, context, actions): + """Receive work payload and build the action_types lookup.""" + super().start(task, context, actions) + self._action_types = {at.name: at for at in self.actions} + + @staticmethod + def _load_actions(trajectory_path: Path) -> list[dict]: + """Extract action events from a trajectory JSONL file.""" + actions = [] + with open(trajectory_path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + event = json.loads(line) + if event.get("event") == "action": + actions.append(event["action"]) + return actions + + def react(self, observation: Observation | None) -> Action | None: + if self._step >= len(self._actions): + return None # No more recorded actions — signal done + + action_data = self._actions[self._step] + self._step += 1 + + name = action_data.get("name", "") + arguments = action_data.get("arguments", {}) + + action_type = self._action_types.get(name) + if action_type is None: + from ...core.actions import build_unknown_action + + return build_unknown_action(name, arguments) + + return action_type.build_action(arguments) + + def close(self) -> None: + pass + + +class ReplayAgent(Agent): + """Agent that replays pre-recorded actions from a trajectory file.""" + + display_name: ClassVar[str] = "Replay Agent" + slug_name: ClassVar[str] = "replay" + recording: str # Path to the recording directory (or trajectory.jsonl file) + runner: str | None = "direct" # No external deps — run in host process + + @classmethod + def _get_instance_class(cls): + return ReplayAgentInstance + + def _get_instance_kwargs( + self, + session_id: str, + ) -> dict[str, Any]: + recording_path = Path(self.recording) + if recording_path.is_file(): + trajectory_path = recording_path + else: + trajectory_path = recording_path / "trajectory.jsonl" + return { + "session_id": session_id, + "trajectory_path": trajectory_path, + } diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/replay/replay_benchmark.py b/labs/AgentStream/exgentic/src/exgentic/agents/replay/replay_benchmark.py new file mode 100644 index 00000000..c9af05f9 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/replay/replay_benchmark.py @@ -0,0 +1,92 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""ReplayBenchmark — replays a full recorded session (agent + environment). + +Pairs with ReplayAgent + ReplaySession to test the full execution loop +without needing any benchmark dependencies installed. + +Usage (from tests):: + + benchmark = ReplayBenchmark(recording_dir="path/to/recording") + agent = ReplayAgent(recording="path/to/recording") + results = evaluate(benchmark=benchmark, agent=agent) +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, ClassVar + +from ...core.benchmark import Benchmark +from ...core.evaluator import Evaluator +from ...core.types import BenchmarkResults, SessionIndex +from .replay_session import ReplaySession + + +class ReplayEvaluator(Evaluator): + """Evaluator that returns session kwargs for ReplaySession.""" + + def __init__(self, recording_dir: str) -> None: + self._recording_dir = recording_dir + + def list_tasks(self) -> list[str]: + recording = Path(self._recording_dir) + # Try to get task_id from session.json + manifest_path = recording / "session.json" + if manifest_path.exists(): + manifest = json.loads(manifest_path.read_text()) + task_id = manifest.get("task_id", "0") + return [str(task_id)] + return ["0"] + + def get_session_kwargs(self, index: SessionIndex) -> dict[str, Any]: + return { + "recording_dir": self._recording_dir, + "session_id": index.session_id, + } + + def aggregate_sessions(self, sessions: list[SessionIndex]) -> BenchmarkResults: + paths = self.get_sessions_paths(sessions) + scores = [] + for p in paths: + results_path = p.benchmark_results + if results_path.exists(): + data = json.loads(results_path.read_text()) + score = data.get("score") + if score is not None: + scores.append(float(score)) + + avg_score = sum(scores) / len(scores) if scores else 0.0 + return BenchmarkResults( + benchmark_name="replay", + total_tasks=len(sessions), + score=avg_score, + ) + + +class ReplayBenchmark(Benchmark): + """Benchmark that replays recorded sessions from a directory.""" + + display_name: ClassVar[str] = "Replay Benchmark" + slug_name: ClassVar[str] = "replay" + recording_dir: str + + @classmethod + def _get_evaluator_class(cls): + return ReplayEvaluator + + @classmethod + def _get_session_class(cls): + return ReplaySession + + def _get_evaluator_kwargs(self) -> dict[str, Any]: + return {"recording_dir": self.recording_dir} + + def runner_kwargs(self) -> dict[str, Any]: + kw = super().runner_kwargs() + if self.resolve_runner() == "docker": + recording_dir = str(Path(self.recording_dir).resolve()) + kw.setdefault("volumes", {})[recording_dir] = recording_dir + return kw diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/replay/replay_session.py b/labs/AgentStream/exgentic/src/exgentic/agents/replay/replay_session.py new file mode 100644 index 00000000..e13b38ee --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/replay/replay_session.py @@ -0,0 +1,139 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""ReplaySession — replays recorded observations from a trajectory. + +Used together with ReplayAgent to test the full execution loop +without needing any benchmark dependencies installed. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from json_schema_to_pydantic import create_model + +from ...core.session import Session +from ...core.types import Action, ActionType, Observation, SessionScore, SingleAction, SingleObservation + + +def _action_type_from_schema(entry: dict) -> ActionType: + """Reconstruct an ActionType from a session.json action entry.""" + schema = entry["arguments_schema"] + args_model = create_model(schema) + + # Create a SingleAction subclass with the right name + arguments type + action_cls = type( + f"{entry['name']}_Action", + (SingleAction,), + {"__annotations__": {"name": str, "arguments": args_model}}, + ) + + return ActionType( + name=entry["name"], + description=entry.get("description", ""), + cls=action_cls, + is_finish=entry.get("is_finish", False), + is_message=entry.get("is_message", False), + is_hidden=entry.get("is_hidden", False), + ) + + +class ReplaySession(Session): + """Session that replays recorded observations from a trajectory file. + + Does not require any benchmark dependencies — everything is + reconstructed from the recording (session.json + trajectory.jsonl). + """ + + def __init__( + self, + recording_dir: str, + *, + session_id: str | None = None, + ) -> None: + recording = Path(recording_dir) + manifest = json.loads((recording / "session.json").read_text()) + + self._task_id_val = manifest.get("task_id", "") + self._task_val = manifest.get("task", "") + self._context_val = manifest.get("context", {}) + self._action_types = [_action_type_from_schema(a) for a in manifest.get("actions", [])] + + # Load recorded observations and score from trajectory/results + self._observations: list[Any] = [] + trajectory = recording / "trajectory.jsonl" + with open(trajectory, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + event = json.loads(line) + if event.get("event") == "observation" and event.get("observation") is not None: + self._observations.append(event["observation"]) + + # Load recorded score + results_path = recording / "results.json" + if results_path.exists(): + results = json.loads(results_path.read_text()) + self._recorded_score = results.get("details", results) + else: + self._recorded_score = {"score": 0.0, "success": False, "is_finished": True} + + self._step_idx = 0 + self._done = False + + if session_id is not None: + self._session_id = session_id + + super().__init__() + + @property + def task_id(self) -> str: + return self._task_id_val + + @property + def task(self) -> str: + return self._task_val + + @property + def context(self) -> dict[str, Any]: + return self._context_val + + @property + def actions(self) -> list[ActionType]: + return self._action_types + + def _next_observation(self) -> Observation | None: + if self._step_idx < len(self._observations): + obs = self._observations[self._step_idx] + self._step_idx += 1 + return SingleObservation(result=obs.get("result") if isinstance(obs, dict) else obs) + return None + + def start(self) -> Observation | None: + return self._next_observation() + + def step(self, action: Action) -> Observation | None: + obs = self._next_observation() + if obs is None: + self._done = True + return obs + + def done(self) -> bool: + return self._done + + def score(self) -> SessionScore: + data = self._recorded_score + return SessionScore( + score=float(data.get("score", 0.0)), + success=bool(data.get("success", False)), + is_finished=data.get("is_finished", True), + session_metrics=data.get("session_metrics", {}), + session_metadata=data.get("session_metadata", {}), + ) + + def close(self) -> None: + pass diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/smolagents/__init__.py b/labs/AgentStream/exgentic/src/exgentic/agents/smolagents/__init__.py new file mode 100644 index 00000000..882dc64e --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/smolagents/__init__.py @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from .base_agent import SmolagentBaseAgent +from .code_agent import SmolagentCodeAgent +from .tool_calling_agent import SmolagentToolCallingAgent + +__all__ = [ + "SmolagentBaseAgent", + "SmolagentCodeAgent", + "SmolagentToolCallingAgent", +] diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/smolagents/base_agent.py b/labs/AgentStream/exgentic/src/exgentic/agents/smolagents/base_agent.py new file mode 100644 index 00000000..1a0634ec --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/smolagents/base_agent.py @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from typing import Any, ClassVar + +from ...core.agent import Agent +from ...core.types import ModelSettings + + +class SmolagentBaseAgent(Agent): + display_name: ClassVar[str] = "SmolAgents Base Agent" + slug_name: ClassVar[str] = "smolagents_base" + + model: str = "watsonx/meta-llama/llama-3-3-70b-instruct" + max_steps: int = 150 + model_settings: ModelSettings | None = None + retry_on_all_errors: bool = True + + def _get_instance_kwargs( + self, + session_id: str, + ) -> dict[str, Any]: + return { + "session_id": session_id, + "model_id": self.model, + "max_steps": self.max_steps, + "model_settings": self.model_settings, + "retry_on_all_errors": self.retry_on_all_errors, + } + + @property + def model_name(self) -> str: # type: ignore[override] + return str(self.model).split("/")[-1] + + def get_models_names(self) -> list[str]: # type: ignore[override] + return [str(self.model)] diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/smolagents/base_instance.py b/labs/AgentStream/exgentic/src/exgentic/agents/smolagents/base_instance.py new file mode 100644 index 00000000..8a1ce679 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/smolagents/base_instance.py @@ -0,0 +1,152 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import functools +import logging +from abc import abstractmethod +from collections.abc import Callable + +from rich.console import Console +from smolagents import LiteLLMModel +from smolagents.models import is_rate_limit_error +from smolagents.monitoring import AgentLogger, LogLevel +from smolagents.tools import Tool, tool +from smolagents.utils import AgentError, Retrying + +from ...adapters.agents.code_agent import CodeAgentInstance +from ...core.context import get_context +from ...core.types import ModelSettings, RetryStrategy +from ...integrations.litellm.health import check_model_accessible_sync +from ...observers.logging import close_logger +from ...utils.cost import CostReport, LiteLLMCostReport +from ...utils.settings import get_settings + +settings = get_settings() + + +class ContextInjectingLiteLLMModel(LiteLLMModel): + """Wrapper around LiteLLMModel that injects context into litellm_metadata.""" + + def generate(self, *args, **kwargs): + """Inject context into litellm_metadata before calling the model.""" + # Use 'metadata' parameter instead of 'litellm_metadata' + # LiteLLM passes 'metadata' to callbacks in litellm_params.metadata + kwargs.setdefault("metadata", {})["context"] = get_context() + + return super().generate(*args, **kwargs) + + +class SmolagentBaseAgentInstance(CodeAgentInstance): + def __init__( + self, + session_id: str, + model_id: str, + max_steps: int = 150, + model_settings: ModelSettings | None = None, + retry_on_all_errors: bool = True, + ): + super().__init__(session_id) + self.model_id = model_id + self.max_steps = max_steps + if model_settings is None: + self.model_settings = ModelSettings() + elif isinstance(model_settings, ModelSettings): + self.model_settings = model_settings + else: + raise ValueError("model_settings must be a ModelSettings instance.") + self._retry_on_all_errors = retry_on_all_errors + self._agent = None + self._model = None + + # Check model accessibility + check_model_accessible_sync(self.model_id, logger=self.logger) + + def run_code_agent(self, functions: list[Callable]) -> None: + def _wrap_tool(fn: Callable) -> Callable: + @functools.wraps(fn) + def wrapper(*args, **kwargs): + try: + return fn(*args, **kwargs) + except RuntimeError as exc: + if "after close" in str(exc): + agent_logger = self.get_smolagent_logger() + if agent_logger is None: + agent_logger = AgentLogger( + console=Console(), + level=LogLevel.ERROR, + ) + raise AgentError("Agent interrupted (session closed).", agent_logger) from exc + raise + + return wrapper + + tools = [tool(_wrap_tool(function)) for function in functions] + return self.run_smolagent(tools=tools) + + def get_smolagent_logger(self): + smolagent_logger = None + for handler in self.logger.handlers: + if isinstance(handler, logging.FileHandler): + console = Console( + file=handler.stream, + force_terminal=False, + color_system=None, + highlight=False, + ) + smolagent_logger = AgentLogger(console=console, level=LogLevel.DEBUG) + return smolagent_logger + + def get_internal_model(self): + if self._model is None: + temperature = self.model_settings.temperature + self._model = ContextInjectingLiteLLMModel( + model_id=self.model_id, + temperature=temperature if temperature is not None else 1.0, + max_tokens=self.model_settings.max_tokens, + caching=settings.litellm_caching, + ) + num_retries = self.model_settings.num_retries or 0 + max_attempts = num_retries + 1 if num_retries > 0 else 1 + retry_strategy = self.model_settings.retry_strategy.value + exponential_base = 2.0 if retry_strategy == RetryStrategy.EXPONENTIAL_BACKOFF.value else 1.0 + log_level = logging._nameToLevel.get(settings.log_level, logging.INFO) + self._model.retryer = Retrying( + max_attempts=max_attempts, + wait_seconds=self.model_settings.retry_after, + exponential_base=exponential_base, + jitter=False, + retry_predicate=self.retry_predicate, + reraise=True, + before_sleep_logger=(self.logger, log_level), + after_logger=None, + ) + return self._model + + def retry_predicate(self, exc: BaseException) -> bool: + if self._retry_on_all_errors: + return True + return is_rate_limit_error(exc) + + @abstractmethod + def run_smolagent(self, tools: list[Tool]): + raise NotImplementedError + + def close(self): + self.logger.info("Interrupting Smolagent...") + if self._agent is not None: + self._agent.interrupt() + super().close() + self.logger.debug("Closing logger.") + close_logger(self.logger) + + def get_cost(self) -> CostReport: + if self._agent is None: + return LiteLLMCostReport.initialize_empty(model_name=self.model_id) + + token_usage = self._agent.monitor.get_total_token_counts() + + return LiteLLMCostReport.from_token_counts( + model_name=self.model_id, + input_tokens=token_usage.input_tokens, + output_tokens=token_usage.output_tokens, + ) diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/smolagents/code_agent.py b/labs/AgentStream/exgentic/src/exgentic/agents/smolagents/code_agent.py new file mode 100644 index 00000000..9466f716 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/smolagents/code_agent.py @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from typing import ClassVar + +from .base_agent import SmolagentBaseAgent + + +class SmolagentCodeAgent(SmolagentBaseAgent): + display_name: ClassVar[str] = "SmolAgents Code" + slug_name: ClassVar[str] = "smolagents_code" + + @classmethod + def _get_instance_class(cls): + from .code_instance import SmolagentCodeAgentInstance + + return SmolagentCodeAgentInstance + + @classmethod + def _get_instance_class_ref(cls) -> str: + return "exgentic.agents.smolagents.code_instance:SmolagentCodeAgentInstance" diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/smolagents/code_instance.py b/labs/AgentStream/exgentic/src/exgentic/agents/smolagents/code_instance.py new file mode 100644 index 00000000..5d52cb8c --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/smolagents/code_instance.py @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import os + +import yaml +from smolagents import CodeAgent as SmolagentBaseCodeAgent +from smolagents.tools import Tool +from smolagents.utils import AgentError + +from .base_instance import SmolagentBaseAgentInstance + + +class SmolagentCodeAgentInstance(SmolagentBaseAgentInstance): + """Smolagent implementation.""" + + def run_smolagent(self, tools: list[Tool]): + # Load custom structured prompt templates from YAML next to this module + prompt_path = os.path.join(os.path.dirname(__file__), "structured_code_agent.yaml") + try: + with open(prompt_path, encoding="utf-8-sig") as f: + prompt_templates = yaml.safe_load(f) + except Exception: + prompt_templates = None + + self._agent = SmolagentBaseCodeAgent( + tools=tools, + model=self.get_internal_model(), + prompt_templates=prompt_templates, + use_structured_outputs_internally=True, + logger=self.get_smolagent_logger(), + ) + # Remove built-in final_answer; termination should happen by interacting with the benchmark (finish action). + self._agent.tools.pop("final_answer", None) + + prompt = f"Task: {self.task}\n\n" + if self.context: + prompt += f"Context: {self.context}\n\n" + prompt += ( + "Complete this task using the available functions. " + "Each function corresponds to an action you can take to solve the given task.\n" + "Every action should be taken only by calling one of the functions. " + "If one function fail, consider using another, at any given point one of the functions\n" + "can be a valid next step. At any point you should executing actions by writing code. " + "do not call tools with tool calling mechanism.\n\n" + "Printing or any other code will be visible only by you alone.\n\n" + # "Always provide parameter names when calling function. Do not rely on positional arguments.\n" + ) + if self.initial_observation is not None and not self.initial_observation.is_empty(): + text = str(self.initial_observation).strip() + if text: + prompt += f"\nFirst Observation: {text}\n" + try: + self._agent.run(task=prompt, max_steps=self.max_steps) + + except AgentError as e: + self.logger.info(f"AgentError: {e}") + raise diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/smolagents/requirements.txt b/labs/AgentStream/exgentic/src/exgentic/agents/smolagents/requirements.txt new file mode 100644 index 00000000..f7a97d41 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/smolagents/requirements.txt @@ -0,0 +1 @@ +smolagents>=1.13.0 diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/smolagents/structured_code_agent.yaml b/labs/AgentStream/exgentic/src/exgentic/agents/smolagents/structured_code_agent.yaml new file mode 100644 index 00000000..411dc2bb --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/smolagents/structured_code_agent.yaml @@ -0,0 +1,257 @@ +system_prompt: |- + You are an expert assistant who can solve any task using code blobs. You will be given a task to solve as best you can. + To do so, you have been given access to a list of tools: these tools are basically Python functions which you can call with code. + To solve the task, you must plan forward to proceed in a series of steps, in a cycle of 'Thought:', 'Code:', and 'Observation:' sequences. + + At each step, in the 'Thought:' attribute, you should first explain your reasoning towards solving the task and the tools that you want to use. + Then in the 'Code' attribute, you should write the code in simple Python. + During each intermediate step, you can use 'print()' to save whatever important information you will then need. + These print outputs will then appear in the 'Observation:' field, which will be available as input for the next step. + In the end, if a completion tool is available (e.g., finish or a submit_* tool), call it to deliver the final answer. Read that tool's description for the exact required format. You will be generating a JSON object with the following structure: + ```json + { + "thought": "...", + "code": "..." + } + ``` + + Here are a few examples using notional tools (note: "finish" in examples is a placeholder for the actual completion tool provided in your tools list; use the real tool name and follow its description): + --- + Task: "Generate an image of the oldest person in this document." + + {"thought": "I will proceed step by step and use the following tools: `document_qa` to find the oldest person in the document, then `image_generator` to generate an image according to the answer.", "code": "answer = document_qa(document=document, question=\"Who is the oldest person mentioned?\")\nprint(answer)\n"} + Observation: "The oldest person in the document is John Doe, a 55 year old lumberjack living in Newfoundland." + + {"thought": "I will now generate an image showcasing the oldest person.", "code": "image = image_generator(\"A portrait of John Doe, a 55-year-old man living in Canada.\")\nfinish(image)\n"} + --- + Task: "What is the result of the following operation: 5 + 3 + 1294.678?" + + {"thought": "I will use python code to compute the result of the operation and then return the final answer using the completion tool.", "code": "result = 5 + 3 + 1294.678\nfinish(result)\n"} + + --- + Task: + In a 1979 interview, Stanislaus Ulam discusses with Martin Sherwin about other great physicists of his time, including Oppenheimer. + What does he say was the consequence of Einstein learning too much math on his creativity, in one word? + + {"thought": "I need to find and read the 1979 interview of Stanislaus Ulam with Martin Sherwin.", "code": "pages = web_search(query=\"1979 interview Stanislaus Ulam Martin Sherwin physicists Einstein\")\nprint(pages)\n"} + Observation: + No result found for query "1979 interview Stanislaus Ulam Martin Sherwin physicists Einstein". + + {"thought": "The query was maybe too restrictive and did not find any results. Let's try again with a broader query.", "code": "pages = web_search(query=\"1979 interview Stanislaus Ulam\")\nprint(pages)\n"} + Observation: + Found 6 pages: + [Stanislaus Ulam 1979 interview](https://ahf.nuclearmuseum.org/voices/oral-histories/stanislaus-ulams-interview-1979/) + + [Ulam discusses Manhattan Project](https://ahf.nuclearmuseum.org/manhattan-project/ulam-manhattan-project/) + + (truncated) + + {"thought": "I will read the first 2 pages to know more.", "code": "for url in [\"https://ahf.nuclearmuseum.org/voices/oral-histories/stanislaus-ulams-interview-1979/\", \"https://ahf.nuclearmuseum.org/manhattan-project/ulam-manhattan-project/\"]:\n whole_page = visit_webpage(url)\n print(whole_page)\n print(\"\n\" + \"=\"*80 + \"\n\") # Print separator between pages"} + + Observation: + Manhattan Project Locations: + Los Alamos, NM + Stanislaus Ulam was a Polish-American mathematician. He worked on the Manhattan Project at Los Alamos and later helped design the hydrogen bomb. In this interview, he discusses his work at + (truncated) + + {"thought": "I now have the final answer: from the webpages visited, Stanislaus Ulam says of Einstein: \"He learned too much mathematics and sort of diminished, it seems to me personally, it seems to me his purely physics creativity.\" Let's answer in one word.", "code": "finish(\"diminished\")"} + + --- + Task: "Which city has the highest population: Guangzhou or Shanghai?" + + {"thought": "I need to get the populations for both cities and compare them: I will use the tool `web_search` to get the population of both cities.", "code": "for city in [\"Guangzhou\", \"Shanghai\"]:\n print(f\"Population {city}:\", web_search(f\"{city} population\")"} + Observation: + Population Guangzhou: ['Guangzhou has a population of 15 million inhabitants as of 2021.'] + Population Shanghai: '26 million (2019)' + + {"thought": "Now I know that Shanghai has the highest population.", "code": "finish(\"Shanghai\")"} + + --- + Task: "What is the current age of the pope, raised to the power 0.36?" + + {"thought": "I will use the tool `wikipedia_search` to get the age of the pope, and confirm that with a web search.", "code": "pope_age_wiki = wikipedia_search(query=\"current pope age\")\nprint(\"Pope age as per wikipedia:\", pope_age_wiki)\npope_age_search = web_search(query=\"current pope age\")\nprint(\"Pope age as per google search:\", pope_age_search)"} + Observation: + Pope age: "The pope Francis is currently 88 years old." + + {"thought": "I know that the pope is 88 years old. Let's compute the result using python code.", "code": "pope_current_age = 88 ** 0.36\nfinish(pope_current_age)"} + + Above example were using notional tools that might not exist for you. On top of performing computations in the Python code snippets that you create, you only have access to these tools, behaving like regular python functions: + ```python + {%- for tool in tools.values() %} + {{ tool.to_code_prompt() }} + {% endfor %} + ``` + + {%- if managed_agents and managed_agents.values() | list %} + You can also give tasks to team members. + Calling a team member works similarly to calling a tool: provide the task description as the 'task' argument. Since this team member is a real human, be as detailed and verbose as necessary in your task description. + You can also include any relevant variables or context using the 'additional_args' argument. + Here is a list of the team members that you can call: + ```python + {%- for agent in managed_agents.values() %} + def {{ agent.name }}(task: str, additional_args: dict[str, Any]) -> str: + """{{ agent.description }} + + Args: + task: Long detailed description of the task. + additional_args: Dictionary of extra inputs to pass to the managed agent, e.g. images, dataframes, or any other contextual data it may need. + """ + {% endfor %} + ``` + {%- endif %} + + {%- if custom_instructions %} + {{custom_instructions}} + {%- endif %} + + Here are the rules you should always follow to solve your task: + 1. Use only variables that you have defined! + 2. Always use the right arguments for the tools. DO NOT pass the arguments as a dict as in 'answer = wikipedia_search({'query': "What is the place where James Bond lives?"})', but use the arguments directly as in 'answer = wikipedia_search(query="What is the place where James Bond lives?")'. + 3. Take care to not chain too many sequential tool calls in the same code block, especially when the output format is unpredictable. For instance, a call to wikipedia_search has an unpredictable return format, so do not have another tool call that depends on its output in the same block: rather output results with print() to use them in the next block. + 4. Call a tool only when needed, and never re-do a tool call that you previously did with the exact same parameters. + 5. Don't name any new variable with the same name as a tool: for instance don't name a variable 'final_answer'. + 6. Never create any notional variables in our code, as having these in your logs will derail you from the true variables. + 7. You can use imports in your code, but only from the following list of modules: {{authorized_imports}} + 8. The state persists between code executions: so if in one step you've created variables or imported modules, these will all persist. + 9. Don't give up! You're in charge of solving the task, not providing directions to solve it. + + Now Begin! +planning: + initial_plan: |- + You are a world expert at analyzing a situation to derive facts, and plan accordingly towards solving a task. + Below I will present you a task. You will need to 1. build a survey of facts known or needed to solve the task, then 2. make a plan of action to solve the task. + + ## 1. Facts survey + You will build a comprehensive preparatory survey of which facts we have at our disposal and which ones we still need. + These "facts" will typically be specific names, dates, values, etc. Your answer should use the below headings: + ### 1.1. Facts given in the task + List here the specific facts given in the task that could help you (there might be nothing here). + + ### 1.2. Facts to look up + List here any facts that we may need to look up. + Also list where to find each of these, for instance a website, a file... - maybe the task contains some sources that you should reuse here. + + ### 1.3. Facts to derive + List here anything that we want to derive from the above by logical reasoning, for instance computation or simulation. + + Don't make any assumptions. For each item, provide a thorough reasoning. Do not add anything else on top of three headings above. + + ## 2. Plan + Then for the given task, develop a step-by-step high-level plan taking into account the above inputs and list of facts. + This plan should involve individual tasks based on the available tools, that if executed correctly will yield the correct answer. + Do not skip steps, do not add any superfluous steps. Only write the high-level plan, DO NOT DETAIL INDIVIDUAL TOOL CALLS. + After writing the final step of the plan, write the '' tag and stop there. + + You can leverage these tools, behaving like regular python functions: + ```python + {%- for tool in tools.values() %} + {{ tool.to_code_prompt() }} + {% endfor %} + ``` + + {%- if managed_agents and managed_agents.values() | list %} + You can also give tasks to team members. + Calling a team member works similarly to calling a tool: provide the task description as the 'task' argument. Since this team member is a real human, be as detailed and verbose as necessary in your task description. + You can also include any relevant variables or context using the 'additional_args' argument. + Here is a list of the team members that you can call: + ```python + {%- for agent in managed_agents.values() %} + def {{ agent.name }}(task: str, additional_args: dict[str, Any]) -> str: + """{{ agent.description }} + + Args: + task: Long detailed description of the task. + additional_args: Dictionary of extra inputs to pass to the managed agent, e.g. images, dataframes, or any other contextual data it may need. + """ + {% endfor %} + ``` + {%- endif %} + + --- + Now begin! Here is your task: + ``` + {{task}} + ``` + First in part 1, write the facts survey, then in part 2, write your plan. + update_plan_pre_messages: |- + You are a world expert at analyzing a situation, and plan accordingly towards solving a task. + You have been given the following task: + ``` + {{task}} + ``` + + Below you will find a history of attempts made to solve this task. + You will first have to produce a survey of known and unknown facts, then propose a step-by-step high-level plan to solve the task. + If the previous tries so far have met some success, your updated plan can build on these results. + If you are stalled, you can make a completely new plan starting from scratch. + + Find the task and history below: + update_plan_post_messages: |- + Now write your updated facts below, taking into account the above history: + ## 1. Updated facts survey + ### 1.1. Facts given in the task + ### 1.2. Facts that we have learned + ### 1.3. Facts still to look up + ### 1.4. Facts still to derive + + Then write a step-by-step high-level plan to solve the task above. + ## 2. Plan + ### 2. 1. ... + Etc. + This plan should involve individual tasks based on the available tools, that if executed correctly will yield the correct answer. + Beware that you have {remaining_steps} steps remaining. + Do not skip steps, do not add any superfluous steps. Only write the high-level plan, DO NOT DETAIL INDIVIDUAL TOOL CALLS. + After writing the final step of the plan, write the '' tag and stop there. + + You can leverage these tools, behaving like regular python functions: + ```python + {%- for tool in tools.values() %} + {{ tool.to_code_prompt() }} + {% endfor %} + ``` + + {%- if managed_agents and managed_agents.values() | list %} + You can also give tasks to team members. + Calling a team member works similarly to calling a tool: provide the task description as the 'task' argument. Since this team member is a real human, be as detailed and verbose as necessary in your task description. + You can also include any relevant variables or context using the 'additional_args' argument. + Here is a list of the team members that you can call: + ```python + {%- for agent in managed_agents.values() %} + def {{ agent.name }}(task: str, additional_args: dict[str, Any]) -> str: + """{{ agent.description }} + + Args: + task: Long detailed description of the task. + additional_args: Dictionary of extra inputs to pass to the managed agent, e.g. images, dataframes, or any other contextual data it may need. + """ + {% endfor %} + ``` + {%- endif %} + + Now write your updated facts survey below, then your new plan. +managed_agent: + task: |- + You're a helpful agent named '{{name}}'. + You have been submitted this task by your manager. + --- + Task: + {{task}} + --- + You're helping your manager solve a wider task: so make sure to not provide a one-line answer, but give as much information as possible to give them a clear understanding of the answer. + + Your final submission via the completion tool MUST contain these parts unless the tool description specifies a different format (in which case, follow the tool description exactly): + ### 1. Task outcome (short version): + ### 2. Task outcome (extremely detailed version): + ### 3. Additional context (if relevant): + + Put all these in the completion tool call, and read the tool's description for the required argument names and format. Everything that you do not pass as an argument to that tool will be lost. + And even if your task resolution is not successful, please return as much context as possible, so that your manager can act upon this feedback. + report: |- + Here is the final answer from your managed agent '{{name}}': + {{final_answer}} +final_answer: + pre_messages: |- + An agent tried to answer a user query but it got stuck and failed to do so. You are tasked with providing an answer instead. Here is the agent's memory: + post_messages: |- + Based on the above, please provide an answer to the following user task: + {{task}} diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/smolagents/tool_calling_agent.py b/labs/AgentStream/exgentic/src/exgentic/agents/smolagents/tool_calling_agent.py new file mode 100644 index 00000000..7f3bccb7 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/smolagents/tool_calling_agent.py @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from typing import ClassVar + +from .base_agent import SmolagentBaseAgent + + +class SmolagentToolCallingAgent(SmolagentBaseAgent): + display_name: ClassVar[str] = "SmolAgents Tool Calling" + slug_name: ClassVar[str] = "smolagents_tool" + + @classmethod + def _get_instance_class(cls): + from .tool_calling_instance import SmolagentToolCallingAgentInstance + + return SmolagentToolCallingAgentInstance + + @classmethod + def _get_instance_class_ref(cls) -> str: + return "exgentic.agents.smolagents.tool_calling_instance:SmolagentToolCallingAgentInstance" diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/smolagents/tool_calling_instance.py b/labs/AgentStream/exgentic/src/exgentic/agents/smolagents/tool_calling_instance.py new file mode 100644 index 00000000..9426b003 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/smolagents/tool_calling_instance.py @@ -0,0 +1,37 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from smolagents import ToolCallingAgent +from smolagents.tools import Tool +from smolagents.utils import AgentError + +from .base_instance import SmolagentBaseAgentInstance + + +class SmolagentToolCallingAgentInstance(SmolagentBaseAgentInstance): + """Smolagent implementation.""" + + def run_smolagent(self, tools: list[Tool]): + self._agent = ToolCallingAgent( + tools=tools, + model=self.get_internal_model(), + # use_structured_outputs_internally=True, + logger=self.get_smolagent_logger(), + ) + + prompt = f"Task: {self.task}\n\n" + if self.context: + prompt += f"Context: {self.context}\n\n" + prompt += ( + "Complete this task using the available tools. " + "Each tool corresponds to an action you can take in the environment.\n" + ) + if self.initial_observation is not None and not self.initial_observation.is_empty(): + text = str(self.initial_observation).strip() + if text: + prompt += f"\nFirst Observation: {text}\n" + try: + self._agent.run(task=prompt, max_steps=self.max_steps) + except AgentError as e: + self.logger.info(f"AgentError: {e}") + raise diff --git a/labs/AgentStream/exgentic/src/exgentic/agents/tool_shortlisting.py b/labs/AgentStream/exgentic/src/exgentic/agents/tool_shortlisting.py new file mode 100644 index 00000000..25ee84de --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/agents/tool_shortlisting.py @@ -0,0 +1,101 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The AgentStream organization and its contributors. + +from __future__ import annotations + +import logging +from typing import Any, Callable, List + +from litellm import ( + ChatCompletionDeveloperMessage, + ChatCompletionUserMessage, +) + + +def shortlist_tools( + tools: list[dict[str, Any]], + max_selected: int, + messages: list[Any], + completion_fn: Callable[..., Any], + model: str, + logger: logging.Logger, + *, + cost_callback: Callable[[Any], None] | None = None, +) -> list[dict[str, Any]]: + if len(tools) <= max_selected: + return tools + + logger.info("Tool shortlisting: %d available -> selecting top %d", len(tools), max_selected) + + names = [tool["function"]["name"] for tool in tools] + names_str = "" + for tool in tools: + names_str += f"\n- {tool['function']['name']}: {tool['function']['description']}" + + history_text = _render_history(messages) + + dev = ChatCompletionDeveloperMessage( + role="developer", + content=( + f"Please before providing your next move list the names of the top " + f"{max_selected} tools that are somewhat relevant for the next step, " + "ordered by relevancy (most to least). Return ONLY a JSON object with this shape: " + '{\n "tools": ["tool_name_1", "tool_name_2", ...]\n}.\n' + f"Choose from these tools only: {names_str}.\n" + f"Do not call any of those tools just return the list of the top " + f"{max_selected} relevant tools names in the required format." + ), + ) + history_msg = ChatCompletionUserMessage( + role="user", + content=f"Conversation so far (plain text):\n{history_text}", + ) + + try: + response = completion_fn(model=model, messages=[dev, history_msg]) + except Exception as exc: + logger.warning("Tool shortlisting LLM call failed: %s", exc) + return tools[:max_selected] + + if cost_callback and response and response.usage: + cost_callback(response.usage) + + text = response.choices[0].message.content + if text is None: + text = str(response.choices[0].message) + + positions = [] + for name in names: + idx = text.find(name) + if idx != -1: + positions.append((idx, name)) + + if len(positions) == 0: + logger.info("Tool shortlist fallback: no matches, taking first %d", max_selected) + return tools[:max_selected] + + positions.sort(key=lambda x: x[0]) + selected_names = [name for _, name in positions][:max_selected] + name_to_tool = {tool["function"]["name"]: tool for tool in tools} + selected_tools = [name_to_tool[name] for name in selected_names] + logger.info("Tool shortlist: %d -> %d", len(tools), len(selected_tools)) + return selected_tools + + +def _render_history(messages: list[Any]) -> str: + parts: List[str] = [] + for message in messages: + msg = message if isinstance(message, dict) else dict(message) + role = msg.get("role") or "unknown" + if role == "tool": + content = msg.get("content", "") + parts.append(f"tool: {content}") + continue + content = msg.get("content") + if content: + parts.append(f"{role}: {content}") + tool_calls = msg.get("tool_calls") or [] + for tc in tool_calls: + fn = tc.get("function", {}) if isinstance(tc, dict) else {} + parts.append(f"{role} tool_call: {fn.get('name', '?')}({fn.get('arguments', '')})") + return "\n".join(parts)[-8000:] diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/__init__.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/__init__.py new file mode 100644 index 00000000..367fec41 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/appworld/__init__.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/appworld/__init__.py new file mode 100644 index 00000000..4fa31ba8 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/appworld/__init__.py @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from .appworld_benchmark import AppWorldBenchmark + +__all__ = [ + "AppWorldBenchmark", +] diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/appworld/appworld_benchmark.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/appworld/appworld_benchmark.py new file mode 100644 index 00000000..a7d5c95a --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/appworld/appworld_benchmark.py @@ -0,0 +1,57 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""AppWorld benchmark adapter -- light benchmark class only. + +Evaluator and session classes live in ``appworld_eval.py`` and are loaded +inside the runner subprocess via ``_get_evaluator_class()`` and +``_get_session_class()``. This file must remain importable without the +``appworld`` package installed. +""" + +from __future__ import annotations + +from typing import Any, ClassVar, Literal + +from pydantic import BaseModel, ConfigDict, Field + +from ...core.benchmark import Benchmark +from ...core.types import SingleObservation + + +class AppWorldObservation(SingleObservation): + pass + + +class AppWorldBenchmark(Benchmark, BaseModel): + display_name: ClassVar[str] = "AppWorld" + slug_name: ClassVar[str] = "appworld" + available_subsets: ClassVar[list[str]] = ["train", "dev", "test_normal", "test_challenge"] + model_config = ConfigDict(arbitrary_types_allowed=True) + + @classmethod + def _get_evaluator_class(cls): + return "exgentic.benchmarks.appworld.appworld_eval:AppWorldEvaluator" + + @classmethod + def _get_session_class(cls): + return "exgentic.benchmarks.appworld.appworld_eval:AppWorldSession" + + # Inputs + subset: Literal["train", "dev", "test_normal", "test_challenge"] = "test_challenge" + env_kwargs: dict[str, Any] = Field(default_factory=dict) + max_interactions: int = 200 + tool_name_separator: Literal[".", "__"] = "__" + SCORES_FILE_NAME: ClassVar[str] = "scores.json" + + def list_subsets(self) -> list[str]: # type: ignore[override] + return list(self.available_subsets) + + def _get_evaluator_kwargs(self) -> dict[str, Any]: + return { + "subset": self.subset, + "env_kwargs": self.env_kwargs, + "max_interactions": self.max_interactions, + "tool_name_separator": self.tool_name_separator, + "use_cache": self.use_cache, + } diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/appworld/appworld_eval.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/appworld/appworld_eval.py new file mode 100644 index 00000000..535abcd3 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/appworld/appworld_eval.py @@ -0,0 +1,673 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""AppWorld evaluator and session classes. + +These classes import the ``appworld`` package at method level. They are only +ever instantiated inside the isolated runner subprocess, so the heavy +dependency is never required in the host process. + +The light ``AppWorldBenchmark`` class lives in ``appworld_benchmark.py`` and +must remain importable without the ``appworld`` package installed. +""" + +from __future__ import annotations + +import json +import logging +import shutil +from pathlib import Path +from shutil import copytree +from typing import TYPE_CHECKING, Any, ClassVar, Literal + +from pydantic import ( + BaseModel, + create_model, +) + +from ...core.actions import ActionsHandler +from ...core.evaluator import Evaluator +from ...core.session import Session +from ...core.types import ( + Action, + ActionType, + BenchmarkResults, + EmptyObservation, + FinishAction, + MessageAction, + Observation, + SessionIndex, + SessionScore, + SingleAction, +) +from ...utils.paths import get_run_id, get_run_paths +from ...utils.settings import get_settings +from .appworld_benchmark import AppWorldObservation + +settings = get_settings() +logger = logging.getLogger(__name__) + +APPWORLD_TOTAL_TASKS = { + "train": 90, + "dev": 57, + "test_normal": 168, + "test_challenge": 417, +} + +if TYPE_CHECKING: + from appworld.environment import AppWorld # type: ignore + + +class AppWorldSession(Session): + """Session that hosts AppWorld directly (no separate WorldProcess). + + Tools are derived from the AppWorld task API docs and mapped to ActionTypes. + Each step calls AppWorld.requester.request(app, api, **args) and wraps the + result as observations, surfacing API errors as structured payloads. + """ + + CACHE_DIR: ClassVar[str] = "./appworld_disk_cache" + TASK_OUTPUT_SUBDIR: ClassVar[str] = "task_output" + SCORES_FILE_NAME: ClassVar[str] = "scores.json" + + def __init__( + self, + session_id: str | None = None, + task_spec: dict[str, Any] | None = None, + env_kwargs: dict[str, Any] | None = None, + use_cache: bool = True, + tool_name_separator: str = ".", + max_interactions: int | None = None, + ) -> None: + if session_id is not None: + self._session_id = session_id + self._task_spec = task_spec or {} + self._env_kwargs = env_kwargs or {} + self._tool_name_separator = tool_name_separator + self._max_interactions = max_interactions + self._action_count = 0 + self._registry = ActionsHandler( + logger=self.logger, + warn_on_validation_error=False, + warn_on_unknown_action=False, + handle_validation_error=lambda action, _msg: self.apply_action(action), + handle_unknown_action=self.apply_action, + ) + self._step_count: int = 0 + self._done: bool = False + self._world_closed: bool = False + self._cached_score: SessionScore | None = None + + # Resolve task_id + task_id = self._task_spec.get("task_id") if isinstance(self._task_spec, dict) else None + if isinstance(self._task_spec, str): + task_id = self._task_spec + if not task_id: + raise ValueError("AppWorldSession requires task_spec with 'task_id' or be a task_id string") + self._task_id = str(task_id) + + # Construct AppWorld in-process (lazy import to defer side effects) + from appworld import update_root # type: ignore + from appworld.common.constants import DEFAULT_EXPERIMENT_NAME # type: ignore + from appworld.common.path_store import path_store # type: ignore + from appworld.environment import AppWorld # type: ignore + + # Point appworld at the correct data directory before loading the task. + cache = Path(settings.cache_dir).expanduser() + update_root(str(cache / "appworld")) + + # Patch appworld's SQLite connection helper to allow cross-thread usage. + # The venv runner serves via uvicorn which may dispatch requests across + # threads, but appworld's @lru_cache'd connections default to + # check_same_thread=True, causing ProgrammingError. + self._patch_appworld_sqlite() + + self._world: AppWorld = AppWorld(task_id=self._task_id, **self._env_kwargs) + self._experiment_name: str = self._world.experiment_name or DEFAULT_EXPERIMENT_NAME + self._task_output_dir: Path = ( + Path(path_store.experiment_outputs) / self._experiment_name / "tasks" / self._task_id + ) + + self.logger.info(f"Task ID: {task_spec}") + super().__init__() + + @staticmethod + def _patch_appworld_sqlite() -> None: + """Patch appworld's SQLite helpers to tolerate cross-thread access. + + appworld's ``get_direct_sqlite3_connection`` creates connections with + the default ``check_same_thread=True``, then caches them via + ``@lru_cache``. When the uvicorn runner dispatches requests on + different threads, reusing those connections raises + ``sqlite3.ProgrammingError``. We patch the function and clear the + cache so fresh connections are created with ``check_same_thread=False``. + """ + import sqlite3 as _sqlite3 + + from appworld.apps.lib.models import db as _appworld_db # type: ignore + + _original = _appworld_db.get_direct_sqlite3_connection + + if getattr(_original, "_exgentic_patched", False): + return + + def _safe_connect(db_app_path: str) -> _sqlite3.Connection: + conn = _sqlite3.connect(db_app_path, check_same_thread=False) + conn.execute("PRAGMA mmap_size = 268435456") + return conn + + _safe_connect._exgentic_patched = True # type: ignore[attr-defined] + _appworld_db.get_direct_sqlite3_connection = _safe_connect + # Clear the lru_cache so stale thread-bound connections aren't reused. + _appworld_db.get_direct_cached_sqlite3_connection.cache_clear() + + def get_config(self) -> dict[str, Any]: + return { + "task_spec": self._task_spec, + "env_kwargs": self._env_kwargs, + "tool_name_separator": self._tool_name_separator, + "max_interactions": self._max_interactions, + } + + @property + def world(self) -> AppWorld: + return self._world + + @property + def task(self) -> str: + self.logger.info(f"Task: {self.world.task.instruction}") + return "Task from supervisor:\n" + self.world.task.instruction + + @property + def context(self) -> dict[str, Any]: + if self.world.task is None: + raise ValueError("AppWorld task is not initialized") + + allowed = ", ".join(app for app in self.world.task.allowed_apps if app != "api_docs") + return { + "policy": ( + "This environment provides a set of applications," + " each exposing a predefined set of APIs that may" + " be used to perform tasks on behalf of the" + " supervisor. The applications include:" + f" {allowed}.\n" + " The available applications and their APIs are" + " fixed for the task.\n" + "\n" + "Supervisor account credentials (such as emails," + " usernames, and passwords) are available through" + " the supervisor application's APIs and are" + " accessed from there when required.\n" + "\n" + "If an application requires an access token to" + " perform authenticated operations, the access" + " token is obtained by calling that application's" + " authentication/login API using the credentials" + " retrieved from the supervisor application." + " Access tokens are not provided by the supervisor" + " application.\n" + "\n" + "References to people (e.g., friends, family," + " roommates) correspond to entries in the" + " phone_contacts application.\n" + "References to files or storage correspond to the" + " file_system application, not the local machine" + " filesystem.\n" + "\n" + "Time-based instructions (e.g., 'this month'," + " 'yesterday') are interpreted with full calendar" + " boundary ranges.\n" + "If an API returns paginated results, all pages" + " constitute the complete result.\n" + "\n" + "The environment consists only of the provided" + " applications and their documented APIs and" + " parameters. No additional endpoints, methods," + " arguments, or capabilities are assumed beyond" + " those explicitly defined.\n" + "\n" + "When task execution is finished, the designated" + " task-completion API is used to signal completion." + " If the task requires a final answer value, the" + " answer is returned through that completion API." + " If the task cannot be completed using the" + " available applications and APIs, the task may be" + " marked as failed." + ), + "supervisor": dict(self.world.task.supervisor), + # "app_descriptions": self.world.task.app_descriptions, + # "allowed_apps": self.world.task.allowed_apps, + "datetime": self.world.task.datetime.isoformat(), + } + + @property + def actions(self) -> list[ActionType]: + if not self._registry.actions: + # Build ActionTypes from AppWorld function_calling docs to leverage enriched auth parameters + from ...adapters.schemas.json_schema import make_args_model_from_json_schema + + tools_specs = self.world.task.api_docs.function_calling() + for tool in tools_specs: + function = tool["function"] + raw_name = function["name"] + separator = self._tool_name_separator + name = raw_name.replace("__", separator) + app, api = name.split(separator, 1) + if app == "api_docs": + continue + if api == "show_active_task": + continue + + args_model = make_args_model_from_json_schema(name, function["parameters"]) + + if raw_name == "supervisor__complete_task": + finish_act = create_model( + "AppWorldFinishAction", + __base__=FinishAction, + arguments=(args_model, ...), + ) + self._registry.add_action( + name="finish", + description=function["description"], + action_cls=finish_act, + handler=self.apply_action, + is_finish=True, + ) + else: + act = create_model( + f"{name}_Action", + __base__=SingleAction, + name=(Literal[name], name), + arguments=(args_model, ...), + ) + self._registry.add_action( + name=name, + description=function["description"], + action_cls=act, + handler=self.apply_action, + ) + return self._registry.actions + + @property + def task_id(self) -> str: + return str(self._task_id) + + @property + def _actions_names(self) -> set[str]: + return {a.name for a in self.actions} + + def _to_observation(self, raw: Any, invoking: list[SingleAction] | None = None) -> Observation: + return AppWorldObservation(invoking_actions=invoking or [], result=raw) + + def start(self) -> Observation | None: + self.logger.info(f"session_start id={self.session_id} task_id={self._task_id}") + # Empty initial observation; task details are provided via task/context. + return EmptyObservation() + + def _is_message_action(self, action: SingleAction) -> bool: + if isinstance(action, MessageAction) or action.name == "message": + return True + return False + + def apply_action(self, action: SingleAction): + if self._is_message_action(action): + self._step_count += 1 + return AppWorldObservation( + invoking_actions=[action], + result="Error: Sending a message is not allowed. Please use only one of the available actions.", + ) + # if action.name not in self._actions_names: + # return AppWorldObservation(invoking_actions=[action], result="Wrong name: {action.name}") + # Map benchmark-level finish to the supervisor.complete_task endpoint + effective_name = action.name + if action.name == "finish": + separator = self._tool_name_separator + effective_name = f"supervisor{separator}complete_task" + + separator = self._tool_name_separator + parts = effective_name.split(separator, 1) + if len(parts) != 2: + parts = effective_name, "" + app_name, api_name = parts + + arguments = action.arguments + if isinstance(arguments, BaseModel): + arguments = arguments.model_dump() + + self.logger.info(f"App: {app_name}, Function: {api_name}, Arguments: {arguments}") + + try: + out = self.world.requester.request(app_name, api_name, **arguments) + except Exception as e: + try: + e = json.loads(str(e).split("\n")[-1])["message"] + except json.JSONDecodeError: + pass + out = "Error: " + str(e) + finally: + self._step_count += 1 + + self.logger.info(f"Output: {out}") + + return AppWorldObservation( + invoking_actions=[action], + result=out, + ) + + @staticmethod + def _max_interactions_error(observation: Observation) -> bool: + for obs in observation.to_observation_list(): + result = obs.result + if isinstance(result, str) and "Maximum number of executions" in result: + return True + return False + + def step(self, action: Action) -> Observation | None: + if self._done: + return None + + if self._max_interactions is not None: + incoming = len(action.to_action_list()) + if self._action_count + incoming > self._max_interactions: + self.logger.warning( + "AppWorld local max_interactions reached (%s/%s); terminating session", + self._action_count, + self._max_interactions, + ) + return None + + observation = self._registry.execute(action) + if observation is None: + return None + if self._max_interactions is not None: + self._action_count += len(action.to_action_list()) + if self._max_interactions_error(observation): + self.logger.warning( + "AppWorld max_interactions reached (%s/%s); terminating session", + self.world.num_interactions, + self.world.max_interactions, + ) + return None + return observation + + def done(self) -> bool: + return self.world.task_completed() + + def score(self) -> SessionScore: + if self._cached_score is not None: + return self._cached_score + # World was already saved and closed in close(); compute the actual evaluation score now. + from appworld.apps.lib.models.db import CachedDBHandler + from appworld.evaluator import evaluate_task + + test_tracker = evaluate_task( + task_id=self._task_id, + experiment_name=self._experiment_name, + suppress_errors=True, + save_report=False, + ) + score_value = float(test_tracker.pass_percentage) / 100.0 + self.logger.info( + "Evaluation results: pass_percentage=%s pass_count=%s fail_count=%s num_tests=%s success=%s", + test_tracker.pass_percentage, + test_tracker.pass_count, + test_tracker.fail_count, + test_tracker.num_tests, + test_tracker.success, + ) + + # Check task completion before evaluate_task potentially closes DB. + try: + finished = self.world.task_completed() + except Exception: + finished = bool(self._done) + # Reset cached DB handler for this task so later aggregate evaluation can run. + CachedDBHandler.reset(self._task_id) + # Surface benchmark evaluation details for downstream analysis. + session_metrics = { + "pass_percentage": test_tracker.pass_percentage, + "pass_count": test_tracker.pass_count, + "fail_count": test_tracker.fail_count, + "num_tests": test_tracker.num_tests, + "difficulty": test_tracker.difficulty, + "success": test_tracker.success, + } + tracker_dict = test_tracker.to_dict(stats_only=False) + scores_path = self.paths.benchmark_dir / self.SCORES_FILE_NAME + scores_path.parent.mkdir(parents=True, exist_ok=True) + with open(scores_path, "w", encoding="utf-8") as f: + json.dump( + { + "task_id": self._task_id, + "session_id": self.session_id, + "test_tracker": tracker_dict, + **session_metrics, + }, + f, + ensure_ascii=False, + indent=2, + ) + session_metadata = {"test_tracker": tracker_dict} + sc = SessionScore( + score=score_value, + success=test_tracker.success, + is_finished=finished, + session_metrics=session_metrics, + session_metadata=session_metadata, + ) + # Cache here so a later close()->score() call does not re-run + # evaluate_task() (which fails with IndexError after the task DB has + # already been reset by the first evaluation). + self._cached_score = sc + return sc + + def close(self): + # Save AppWorld task state and mirror logs + self.logger.info( + "Closing AppWorld session: steps=%s done=%s world_closed=%s", + self._step_count, + self._done, + self._world_closed, + ) + if self._world_closed: + self.logger.warning("AppWorld session close called more than once.") + try: + self.world.save() + except Exception: + self.logger.exception("AppWorld world.save failed") + raise + try: + self._done = self.world.task_completed() + except Exception: + # DB may already be closed by score()/evaluate_task; safe to skip. + self.logger.debug("AppWorld task_completed check skipped in close (DB likely closed)") + logs_src = self._task_output_dir / "logs" + if logs_src.exists(): + dest = self.paths.benchmark_dir / "logs" + dest.parent.mkdir(parents=True, exist_ok=True) + try: + copytree(logs_src, dest, dirs_exist_ok=True) + except Exception: + self.logger.exception("AppWorld log copy failed") + raise + task_output_src = self._task_output_dir + if task_output_src.exists(): + task_output_dest = self.paths.benchmark_dir / self.TASK_OUTPUT_SUBDIR + try: + if task_output_dest.exists(): + shutil.rmtree(task_output_dest) + copytree(task_output_src, task_output_dest) + except Exception: + self.logger.exception("AppWorld task output copy failed") + raise + # Write a standardized results.json plus AppWorld-specific fields + try: + sc = self.score() + except Exception: + self.logger.exception("AppWorld evaluation failed") + raise + self._cached_score = sc + try: + self.save_results( + { + "score": sc.score, + "success": sc.success, + "session_id": self.session_id, + "task_id": self._task_id, + "completed": self._done, + "steps": self._step_count, + } + ) + except Exception: + self.logger.exception("AppWorld save_results failed") + raise + self.logger.info( + "Session Finished | Success: %s, steps=%s, score=%s", + self._done, + self._step_count, + sc.score, + ) + try: + self.world.close() + except Exception: + self.logger.exception("AppWorld world.close failed") + raise + self._world_closed = True + experiment_root = self._task_output_dir.parent.parent + if experiment_root.name == self._experiment_name: + try: + shutil.rmtree(experiment_root) + except Exception: + self.logger.exception("AppWorld temp experiment cleanup failed") + + +class AppWorldEvaluator(Evaluator): + """Evaluator for AppWorld -- task discovery, session config, and aggregation.""" + + def __init__( + self, + subset: str = "test_normal", + env_kwargs: dict[str, Any] | None = None, + max_interactions: int = 200, + tool_name_separator: str = "__", + use_cache: bool = True, + ) -> None: + self._subset = subset + self._env_kwargs = env_kwargs or {} + self._max_interactions = max_interactions + self._tool_name_separator = tool_name_separator + self._use_cache = use_cache + self._experiment_name: str = "" + + def _ensure_appworld_root(self) -> None: + from appworld import update_root # type: ignore + + cache = Path(settings.cache_dir).expanduser() + root = str(cache / "appworld") + update_root(root) + + def list_tasks(self) -> list[str]: + from appworld.task import load_task_ids # type: ignore + + self._ensure_appworld_root() + items: list[str] | None = load_task_ids(self._subset) + if not items: + return [] + return [str(t) for t in items] + + def get_session_kwargs(self, index: SessionIndex) -> dict[str, Any]: + self._ensure_appworld_root() + if not self._experiment_name: + self._experiment_name = get_run_id() + task_id = index.task_id + session_id = index.session_id + experiment_name = f"{self._experiment_name}__{session_id}" + spec = {"task_id": task_id} + + return { + "session_id": session_id, + "task_spec": spec, + "env_kwargs": { + **self._env_kwargs, + "max_interactions": self._max_interactions, + "experiment_name": experiment_name, + }, + "use_cache": self._use_cache, + "tool_name_separator": self._tool_name_separator, + "max_interactions": self._max_interactions, + } + + def _stage_task_outputs( + self, + *, + task_ids: list[str], + task_to_session: dict[str, str], + temp_output_dir: Path, + ) -> None: + run_paths = get_run_paths() + for task_id in task_ids: + session_id = task_to_session.get(task_id) + if not session_id: + raise FileNotFoundError(f"Missing session mapping for AppWorld task '{task_id}'.") + session_task_output = run_paths.session(session_id).benchmark_dir / AppWorldSession.TASK_OUTPUT_SUBDIR + if not session_task_output.exists(): + raise FileNotFoundError( + f"Missing staged task output for task='{task_id}' session='{session_id}' at {session_task_output}" + ) + dest_task_dir = temp_output_dir / "tasks" / task_id + dest_task_dir.parent.mkdir(parents=True, exist_ok=True) + copytree(session_task_output, dest_task_dir) + + def aggregate_sessions(self, sessions: list[SessionIndex]) -> BenchmarkResults: + from appworld.evaluator import Metric, TestTracker # type: ignore + + self._ensure_appworld_root() + + if not sessions: + return BenchmarkResults( + benchmark_name="appworld", + total_tasks=0, + score=0.0, + metrics={}, + ) + + run_paths = get_run_paths() + task_id_to_test_tracker: dict[str, TestTracker] = {} + for session in sessions: + task_id = str(session.task_id) + results_path = run_paths.session(session.session_id).results + scores_path = run_paths.session(session.session_id).benchmark_dir / AppWorldSession.SCORES_FILE_NAME + + if scores_path.exists(): + with open(scores_path, encoding="utf-8") as f: + scores_payload = json.load(f) + tracker = scores_payload.get("test_tracker") + else: + logger.warning( + "Missing AppWorld scores file for task_id=%s session_id=%s at %s; " + "falling back to session metadata in %s", + task_id, + session.session_id, + scores_path, + results_path, + ) + with open(results_path, encoding="utf-8") as f: + payload = json.load(f) + tracker = (payload.get("details") or {}).get("session_metadata", {}).get("test_tracker") + if not isinstance(tracker, dict): + raise ValueError( + "Missing test_tracker in aggregation source for " + f"task_id={task_id} session_id={session.session_id}. " + f"Checked {scores_path} and fallback {results_path}." + ) + + task_id_to_test_tracker[task_id] = TestTracker.from_dict(tracker, suppress_errors=False) + + evaluation_dict = Metric.compute_metrics(task_id_to_test_tracker, include_details=True) + report = Metric.build_report(evaluation_dict) + return BenchmarkResults( + benchmark_name="appworld", + total_tasks=len(sessions), + score=evaluation_dict["aggregate"]["task_goal_completion"] / 100, + metrics=report, + ) diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/appworld/requirements.txt b/labs/AgentStream/exgentic/src/exgentic/benchmarks/appworld/requirements.txt new file mode 100644 index 00000000..3ec94d1c --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/appworld/requirements.txt @@ -0,0 +1 @@ +appworld @ git+https://github.com/StonyBrookNLP/appworld.git@edc960129fa6889c2b381715ecd108982029f6d1 diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/appworld/setup.sh b/labs/AgentStream/exgentic/src/exgentic/benchmarks/appworld/setup.sh new file mode 100644 index 00000000..cf9575ba --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/appworld/setup.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v git-lfs >/dev/null 2>&1; then + echo "Error: git-lfs is required but not installed. Install it first: brew install git-lfs (macOS) or apt-get install git-lfs (Linux)" >&2 + exit 1 +fi + +APPWORLD_ROOT="." +export APPWORLD_ROOT + +TMPDIR="$(mktemp -d)" +git lfs install >/dev/null 2>&1 || true +git clone https://github.com/StonyBrookNLP/appworld.git "$TMPDIR/appworld" +cd "$TMPDIR/appworld" +git checkout edc960129fa6889c2b381715ecd108982029f6d1 +git lfs pull + +uv pip install "." + +python -m appworld.cli install + +cd - >/dev/null 2>&1 || true +rm -rf "$TMPDIR" +python -m appworld.cli download data --root "." diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/bfcl/__init__.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/bfcl/__init__.py new file mode 100644 index 00000000..7f1e6dab --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/bfcl/__init__.py @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""BFCL benchmark adapter.""" + +from .bfcl_benchmark import BFCLBenchmark + +__all__ = ["BFCLBenchmark"] diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/bfcl/bfcl_benchmark.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/bfcl/bfcl_benchmark.py new file mode 100644 index 00000000..f804858d --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/bfcl/bfcl_benchmark.py @@ -0,0 +1,93 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""BFCL benchmark adapter — light benchmark class only. + +Evaluator, session, and helper classes live in ``bfcl_eval.py`` +and are loaded inside the runner subprocess via ``_get_evaluator_class()`` +and ``_get_session_class()``. This file must remain importable without +the ``bfcl_eval`` package installed. +""" + +from __future__ import annotations + +from typing import Any, ClassVar, Literal + +from pydantic import BaseModel, ConfigDict + +from ...core import Benchmark +from ...core.types import FinishAction + +BFCLSubset = Literal[ + "simple_python", + "simple_java", + "simple_javascript", + "multiple", + "parallel", + "parallel_multiple", + "irrelevance", + "live_simple", + "live_multiple", + "live_parallel", + "live_parallel_multiple", + "live_irrelevance", + "live_relevance", + "multi_turn_base", + "multi_turn_long_context", + "multi_turn_miss_func", + "multi_turn_miss_param", +] + + +class BFCLFinishArgs(BaseModel): + content: str = "" + + +class BFCLFinishAction(FinishAction): + name: Literal["finish"] = "finish" + arguments: BFCLFinishArgs + + +class BFCLBenchmark(Benchmark, BaseModel): + """BFCL benchmark using Gorilla assets with an Exgentic-native runtime.""" + + display_name: ClassVar[str] = "BFCL" + slug_name: ClassVar[str] = "bfcl" + available_subsets: ClassVar[list[str]] = [ + "simple_python", + "simple_java", + "simple_javascript", + "multiple", + "parallel", + "parallel_multiple", + "irrelevance", + "live_simple", + "live_multiple", + "live_parallel", + "live_parallel_multiple", + "live_irrelevance", + "live_relevance", + "multi_turn_base", + "multi_turn_long_context", + "multi_turn_miss_func", + "multi_turn_miss_param", + ] + model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True) + + @classmethod + def _get_evaluator_class(cls): + return "exgentic.benchmarks.bfcl.bfcl_eval:BFCLEvaluator" + + @classmethod + def _get_session_class(cls): + return "exgentic.benchmarks.bfcl.bfcl_eval:BFCLSession" + + subset: BFCLSubset = "simple_python" + + def list_subsets(self) -> list[str]: # type: ignore[override] + return list(self.available_subsets) + + def _get_evaluator_kwargs(self) -> dict[str, Any]: + return { + "subset": self.subset, + } diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/bfcl/bfcl_eval.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/bfcl/bfcl_eval.py new file mode 100644 index 00000000..46386610 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/bfcl/bfcl_eval.py @@ -0,0 +1,576 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""BFCL evaluator and session classes. + +These classes import bfcl_eval (via bfcl_shim) at runtime. They are only +ever instantiated inside the isolated runner subprocess, so the heavy +``bfcl_eval`` dependency is never required in the host process. +""" + +from __future__ import annotations + +import json +from copy import deepcopy +from pathlib import Path +from typing import Any, Literal + +from pydantic import BaseModel + +from ...adapters.schemas.openai import openai_tools_to_action_types +from ...core.evaluator import Evaluator +from ...core.session import Session +from ...core.types import ( + Action, + ActionType, + BenchmarkResults, + EmptyObservation, + MultiObservation, + SessionIndex, + SessionScore, + SingleAction, + SingleObservation, +) +from .bfcl_benchmark import BFCLFinishAction +from .bfcl_shim import load_bfcl_symbols + +BFCLSubset = Literal[ + "simple_python", + "simple_java", + "simple_javascript", + "multiple", + "parallel", + "parallel_multiple", + "irrelevance", + "live_simple", + "live_multiple", + "live_parallel", + "live_parallel_multiple", + "live_irrelevance", + "live_relevance", + "multi_turn_base", + "multi_turn_long_context", + "multi_turn_miss_func", + "multi_turn_miss_param", +] + + +def _is_relevance_subset(subset: str) -> bool: + return subset in {"irrelevance", "live_irrelevance", "live_relevance"} + + +def _is_multi_turn_subset(subset: str) -> bool: + return subset.startswith("multi_turn_") + + +def _language_for_subset(subset: str, symbols: dict[str, Any]) -> Any: + language = symbols["Language"] + if subset == "simple_java": + return language.JAVA + if subset == "simple_javascript": + return language.JAVASCRIPT + return language.PYTHON + + +def _merge_observations( + *items: SingleObservation | MultiObservation | None, +) -> SingleObservation | MultiObservation | None: + observations: list[SingleObservation] = [] + for item in items: + if item is None: + continue + if isinstance(item, MultiObservation): + observations.extend(item.observations) + continue + observations.append(item) + + if not observations: + return None + if len(observations) == 1: + return observations[0] + return MultiObservation(observations=observations) + + +def _action_arguments_dict(action: SingleAction) -> dict[str, Any]: + arguments = action.arguments + if isinstance(arguments, BaseModel): + return arguments.model_dump() + if isinstance(arguments, dict): + return dict(arguments) + return {"value": arguments} + + +def _render_turn_text(turn_messages: list[dict[str, Any]]) -> str: + parts: list[str] = [] + for message in turn_messages: + if not isinstance(message, dict): + continue + content = str(message.get("content", "")).strip() + if not content: + continue + role = str(message.get("role", "")) + if role == "system": + parts.append(f"System: {content}") + else: + parts.append(content) + return "\n\n".join(parts) + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2, default=str) + + +class BFCLSession(Session): + """Exgentic-native BFCL session with Gorilla-backed scoring.""" + + def __init__( + self, + subset: BFCLSubset, + prompt_entry: dict[str, Any], + possible_answer_entry: dict[str, Any] | None, + session_id: str | None = None, + ) -> None: + if session_id is not None: + self._session_id = session_id + + self.subset = subset + self.prompt_entry = prompt_entry + self.possible_answer_entry = possible_answer_entry + self._task_id = str(prompt_entry["id"]) + self._conversation_turns = [list(turn) for turn in prompt_entry.get("question", [])] + self._turn_texts = [_render_turn_text(turn) for turn in self._conversation_turns] + self._task_text = self._turn_texts[0] if self._turn_texts else "" + self._action_types = self._build_action_types(prompt_entry) + + self._current_turn_index = 0 + self._completed = False + self._result_payload: dict[str, Any] | None = None + + turn_count = max(1, len(self._conversation_turns)) + self._turn_step_calls: list[list[list[str]]] = [[] for _ in range(turn_count)] + self._turn_step_actions: list[list[list[dict[str, Any]]]] = [[] for _ in range(turn_count)] + + super().__init__() + + @property + def task(self) -> str: + return self._task_text + + @property + def context(self) -> dict[str, Any]: + return {"policy": self._build_context_text()} + + @property + def actions(self) -> list[ActionType]: + return self._action_types + + @property + def task_id(self) -> str: + return self._task_id + + def start(self) -> EmptyObservation: + return EmptyObservation() + + def step(self, action: Action) -> SingleObservation | MultiObservation | None: + if self._completed: + return None + if action is None: + raise ValueError("BFCL requires an action or the dedicated finish action.") + + flat_actions = action.to_action_list() + finish_requested = False + finish_action: SingleAction | None = None + tool_actions: list[SingleAction] = [] + for item in flat_actions: + if item.name == "finish": + finish_requested = True + finish_action = item + continue + tool_actions.append(item) + + step_call_strings = [self._action_to_function_call_string(item) for item in tool_actions] + if step_call_strings: + self._turn_step_calls[self._current_turn_index].append(step_call_strings) + self._turn_step_actions[self._current_turn_index].append( + [self._serialize_action(item) for item in tool_actions] + ) + + outputs = self._build_step_output_observation(tool_actions, step_call_strings) + if finish_requested: + # Generate a tool result for the finish action so that every + # tool_call_id in the assistant message has a matching tool + # response. Without this, providers with strict message + # validation (e.g. Azure OpenAI) reject the next request. + finish_obs = SingleObservation( + result="Turn finished.", + invoking_actions=[finish_action], + ) + outputs = _merge_observations(outputs, finish_obs) if outputs else finish_obs + return self._finish_turn(outputs) + + return outputs or EmptyObservation() + + def done(self) -> bool: + return self._completed + + def score(self) -> SessionScore: + if self._result_payload is None: + self._result_payload = self._compute_result_payload() + + return SessionScore( + score=float(self._result_payload["score"]), + success=bool(self._result_payload["success"]), + is_finished=self._result_payload.get("is_finished"), + session_metrics=self._result_payload.get("session_metrics", {}), + session_metadata=self._result_payload.get("session_metadata", {}), + ) + + def close(self) -> None: + if self._result_payload is None: + self._result_payload = self._compute_result_payload() + _write_json(self.paths.benchmark_results, self._result_payload) + + def _build_action_types(self, prompt_entry: dict[str, Any]) -> list[ActionType]: + symbols = load_bfcl_symbols() + functions = self._collect_functions(prompt_entry) + openai_tools = symbols["convert_to_tool"]( + functions, + symbols["GORILLA_TO_OPENAPI"], + symbols["ModelStyle"].OPENAI_COMPLETIONS, + ) + action_types = openai_tools_to_action_types(openai_tools) + action_types.append( + ActionType( + name="finish", + description="End the current BFCL step.", + cls=BFCLFinishAction, + is_finish=True, + ) + ) + return action_types + + def _collect_functions(self, prompt_entry: dict[str, Any]) -> list[dict[str, Any]]: + functions = [deepcopy(item) for item in prompt_entry.get("function", [])] + for items in prompt_entry.get("missed_function", {}).values(): + for item in items: + functions.append(deepcopy(item)) + + deduped: list[dict[str, Any]] = [] + seen: set[str] = set() + for item in functions: + name = str(item.get("name", "")) + if not name or name in seen: + continue + seen.add(name) + deduped.append(item) + return deduped + + def _build_context_text(self) -> str: + if len(self._conversation_turns) <= 1: + return ( + "Complete the task using one or more actions, then call the dedicated " + "finish action. Clarification questions or any type of interaction with " + "the user is not permitted. In this task, actions are recorded rather " + "than executed through a live environment. Calling finish ends the " + "execution." + ) + if _is_multi_turn_subset(self.subset): + return ( + "Complete the current step using one or more actions, then call the " + "dedicated finish action. The finish action ends the current turn only, " + "not the entire session — continue using tools in subsequent turns. " + "Clarification questions or any type of interaction with the user is " + "not permitted." + ) + return ( + "Complete the current step using one or more actions, then call the " + "dedicated finish action. Clarification questions or any type of " + "interaction with the user is not permitted. In this task, actions are " + "recorded rather than executed through a live environment." + ) + + def _build_turn_observation(self, turn_index: int) -> SingleObservation: + if turn_index >= len(self._turn_texts): + return EmptyObservation() + text = self._turn_texts[turn_index] + if not text: + return EmptyObservation() + return SingleObservation( + result=text, + invoking_actions=[], + ) + + def _finish_turn( + self, outputs: SingleObservation | MultiObservation | None + ) -> SingleObservation | MultiObservation | None: + if self._current_turn_index >= len(self._conversation_turns) - 1: + self._completed = True + return outputs + + self._current_turn_index += 1 + next_turn = self._build_turn_observation(self._current_turn_index) + return _merge_observations(outputs, next_turn) or EmptyObservation() + + def _build_step_output_observation( + self, + tool_actions: list[SingleAction], + step_call_strings: list[str], + ) -> SingleObservation | MultiObservation | None: + if not tool_actions: + return None + + if _is_multi_turn_subset(self.subset): + raw_results = self._execute_multi_turn_step(step_call_strings) + else: + raw_results = ["Action recorded." for _ in tool_actions] + + observations = [ + SingleObservation(result=result, invoking_actions=[action]) + for action, result in zip(tool_actions, raw_results, strict=False) + ] + return _merge_observations(*observations) + + def _execute_multi_turn_step(self, step_call_strings: list[str]) -> list[str]: + symbols = load_bfcl_symbols() + execution_results, _ = symbols["execute_multi_turn_func_call"]( + func_call_list=step_call_strings, + initial_config=self.prompt_entry["initial_config"], + involved_classes=self.prompt_entry["involved_classes"], + model_name=f"{symbols['proxy_model_name']}_{self.session_id}_runtime", + test_entry_id=self._task_id, + long_context=("long_context" in self.subset), + is_evaL_run=False, + ) + return execution_results + + def _action_to_function_call_string(self, action: SingleAction) -> str: + arguments = _action_arguments_dict(action) + if not arguments: + return f"{action.name}()" + rendered = ", ".join(f"{key}={value!r}" for key, value in arguments.items()) + return f"{action.name}({rendered})" + + def _serialize_action(self, action: SingleAction) -> dict[str, Any]: + return { + "id": action.id, + "name": action.name, + "arguments": _action_arguments_dict(action), + } + + def _flatten_semantic_actions(self) -> list[dict[str, Any]]: + flattened: list[dict[str, Any]] = [] + for turn in self._turn_step_actions: + for step in turn: + for action in step: + flattened.append({action["name"]: action["arguments"]}) + return flattened + + def _compute_result_payload(self) -> dict[str, Any]: + trace_payload = { + "task_id": self._task_id, + "subset": self.subset, + "turn_step_calls": self._turn_step_calls, + "turn_step_actions": self._turn_step_actions, + } + trace_path = self.paths.benchmark_dir / "bfcl_trace.json" + _write_json(trace_path, trace_payload) + + if not self._completed: + payload = { + "score": 0.0, + "success": False, + "is_finished": False, + "session_metrics": { + "completed_turns": self._current_turn_index, + }, + "session_metadata": { + "bfcl_task_id": self._task_id, + "trace_file": str(trace_path), + }, + } + _write_json(self.paths.benchmark_dir / "bfcl_score.json", payload) + return payload + + try: + if _is_multi_turn_subset(self.subset): + payload = self._score_multi_turn(trace_path) + elif _is_relevance_subset(self.subset): + payload = self._score_relevance(trace_path) + else: + payload = self._score_ast(trace_path) + except Exception as exc: + payload = { + "score": 0.0, + "success": False, + "is_finished": False, + "session_metadata": { + "bfcl_task_id": self._task_id, + "trace_file": str(trace_path), + "error": str(exc), + "error_source": "benchmark", + }, + } + + _write_json(self.paths.benchmark_dir / "bfcl_score.json", payload) + return payload + + def _score_relevance(self, trace_path: Path) -> dict[str, Any]: + tool_call_count = len(self._flatten_semantic_actions()) + success = tool_call_count == 0 if "irrelevance" in self.subset else tool_call_count > 0 + return { + "score": 1.0 if success else 0.0, + "success": success, + "is_finished": True, + "session_metrics": { + "tool_call_count": tool_call_count, + "accuracy": 1.0 if success else 0.0, + }, + "session_metadata": { + "bfcl_task_id": self._task_id, + "trace_file": str(trace_path), + }, + } + + def _score_ast(self, trace_path: Path) -> dict[str, Any]: + if self.possible_answer_entry is None: + raise ValueError(f"Missing ground truth for subset '{self.subset}'.") + + symbols = load_bfcl_symbols() + checker_result = symbols["ast_checker"]( + self.prompt_entry["function"], + self._flatten_semantic_actions(), + self.possible_answer_entry["ground_truth"], + _language_for_subset(self.subset, symbols), + self.subset, + symbols["proxy_model_name"], + ) + score = 1.0 if checker_result["valid"] else 0.0 + return { + "score": score, + "success": bool(checker_result["valid"]), + "is_finished": True, + "session_metrics": { + "accuracy": score, + "action_count": len(self._flatten_semantic_actions()), + }, + "session_metadata": { + "bfcl_task_id": self._task_id, + "trace_file": str(trace_path), + "checker_result": checker_result, + }, + } + + def _score_multi_turn(self, trace_path: Path) -> dict[str, Any]: + if self.possible_answer_entry is None: + raise ValueError(f"Missing ground truth for subset '{self.subset}'.") + + model_turns = self._turn_step_calls + ground_truth_turns = self.possible_answer_entry["ground_truth"] + if len(model_turns) != len(ground_truth_turns): + checker_result = { + "valid": False, + "error_message": ( + "Model was force-terminated before completing all turns. " + f"Observed {len(model_turns)} turns for {len(ground_truth_turns)} ground-truth turns." + ), + "error_type": "multi_turn:force_terminated", + } + else: + symbols = load_bfcl_symbols() + checker_result = symbols["multi_turn_checker"]( + model_turns, + ground_truth_turns, + self.prompt_entry, + self.subset, + f"{symbols['proxy_model_name']}_{self.session_id}_score", + ) + + score = 1.0 if checker_result["valid"] else 0.0 + return { + "score": score, + "success": bool(checker_result["valid"]), + "is_finished": True, + "session_metrics": { + "accuracy": score, + "turn_count": len(model_turns), + }, + "session_metadata": { + "bfcl_task_id": self._task_id, + "trace_file": str(trace_path), + "checker_result": checker_result, + }, + } + + +# ── Evaluator ──────────────────────────────────────────────────────── + + +class BFCLEvaluator(Evaluator): + """Evaluator for BFCL — task discovery, session kwargs, aggregation.""" + + def __init__(self, subset: str = "simple_python") -> None: + self._subset: BFCLSubset = subset # type: ignore[assignment] + self._entries: list[dict[str, Any]] | None = None + self._answers_by_id: dict[str, dict[str, Any]] | None = None + + def _ensure_loaded(self) -> None: + if self._entries is not None and self._answers_by_id is not None: + return + symbols = load_bfcl_symbols() + entries = symbols["load_dataset_entry"](self._subset) + answers = ( + {} + if _is_relevance_subset(self._subset) + else {str(entry["id"]): entry for entry in symbols["load_ground_truth_entry"](self._subset)} + ) + self._entries = entries + self._answers_by_id = answers + + def list_tasks(self) -> list[str]: + self._ensure_loaded() + assert self._entries is not None + return [str(entry["id"]) for entry in self._entries] + + def get_session_kwargs(self, index: SessionIndex) -> dict[str, Any]: + self._ensure_loaded() + assert self._entries is not None + prompt_entry = next( + (entry for entry in self._entries if str(entry["id"]) == str(index.task_id)), + None, + ) + if prompt_entry is None: + raise KeyError(f"Unknown BFCL task id '{index.task_id}' for subset '{self._subset}'.") + answer_entry = None if _is_relevance_subset(self._subset) else self._answers_by_id.get(str(index.task_id)) + return { + "subset": self._subset, + "prompt_entry": prompt_entry, + "possible_answer_entry": answer_entry, + "session_id": index.session_id, + } + + def aggregate_sessions(self, sessions: list[SessionIndex]) -> BenchmarkResults: + payloads: list[dict[str, Any]] = [] + for paths in self.get_sessions_paths(sessions): + result_path = paths.benchmark_results + if not result_path.exists(): + raise FileNotFoundError( + f"Missing BFCL results for planned session '{paths.session_id}' at {result_path}" + ) + with open(result_path, encoding="utf-8") as handle: + payloads.append(json.load(handle)) + + total_tasks = len(payloads) + total_score = sum(float(payload.get("score", 0.0)) for payload in payloads) + successes = sum(1 for payload in payloads if payload.get("success")) + return BenchmarkResults( + benchmark_name=f"bfcl-{self._subset}", + total_tasks=total_tasks, + score=(total_score / total_tasks) if total_tasks else 0.0, + metrics={ + "subset": self._subset, + "success_count": successes, + "accuracy": (total_score / total_tasks) if total_tasks else 0.0, + }, + ) diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/bfcl/bfcl_shim.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/bfcl/bfcl_shim.py new file mode 100644 index 00000000..88fcfafc --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/bfcl/bfcl_shim.py @@ -0,0 +1,68 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Thin import shim around Gorilla's BFCL package.""" + +from __future__ import annotations + +from functools import lru_cache +from typing import Any + +EXGENTIC_BFCL_MODEL = "exgentic-proxy-fc" + + +@lru_cache(maxsize=1) +def load_bfcl_symbols() -> dict[str, Any]: + from bfcl_eval.constants.enums import Language, ModelStyle + from bfcl_eval.constants.model_config import MODEL_CONFIG_MAPPING, ModelConfig + from bfcl_eval.constants.type_mappings import GORILLA_TO_OPENAPI + from bfcl_eval.eval_checker.ast_eval.ast_checker import ast_checker + from bfcl_eval.eval_checker.eval_runner import ( + ast_file_runner, + multi_turn_runner, + relevance_file_runner, + ) + from bfcl_eval.eval_checker.multi_turn_eval.multi_turn_checker import ( + multi_turn_checker, + multi_turn_irrelevance_checker, + ) + from bfcl_eval.eval_checker.multi_turn_eval.multi_turn_utils import ( + execute_multi_turn_func_call, + ) + from bfcl_eval.model_handler.api_inference.openai_completion import OpenAICompletionsHandler + from bfcl_eval.model_handler.base_handler import BaseHandler + from bfcl_eval.model_handler.utils import convert_to_tool + from bfcl_eval.utils import load_dataset_entry, load_ground_truth_entry + + if EXGENTIC_BFCL_MODEL not in MODEL_CONFIG_MAPPING: + MODEL_CONFIG_MAPPING[EXGENTIC_BFCL_MODEL] = ModelConfig( + model_name=EXGENTIC_BFCL_MODEL, + display_name="Exgentic Proxy (FC)", + url="https://github.com/Exgentic/exgentic", + org="Exgentic", + license="Apache 2.0", + model_handler=OpenAICompletionsHandler, + input_price=None, + output_price=None, + is_fc_model=True, + underscore_to_dot=True, + ) + + return { + "BaseHandler": BaseHandler, + "Language": Language, + "ModelStyle": ModelStyle, + "OpenAICompletionsHandler": OpenAICompletionsHandler, + "GORILLA_TO_OPENAPI": GORILLA_TO_OPENAPI, + "convert_to_tool": convert_to_tool, + "load_dataset_entry": load_dataset_entry, + "load_ground_truth_entry": load_ground_truth_entry, + "ast_checker": ast_checker, + "multi_turn_checker": multi_turn_checker, + "multi_turn_irrelevance_checker": multi_turn_irrelevance_checker, + "execute_multi_turn_func_call": execute_multi_turn_func_call, + "ast_file_runner": ast_file_runner, + "multi_turn_runner": multi_turn_runner, + "relevance_file_runner": relevance_file_runner, + "proxy_model_name": EXGENTIC_BFCL_MODEL, + } diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/bfcl/setup.sh b/labs/AgentStream/exgentic/src/exgentic/benchmarks/bfcl/setup.sh new file mode 100644 index 00000000..3af65e29 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/bfcl/setup.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INSTALL_ROOT="${SCRIPT_DIR}/installation" +REPO_DIR="${INSTALL_ROOT}/gorilla" +BFCL_DIR="${REPO_DIR}/berkeley-function-call-leaderboard" +GORILLA_URL="https://github.com/ShishirPatil/gorilla.git" +GORILLA_REF="7ad0134c665944819f88bc50862108d94015968b" # pragma: allowlist secret + +pip_install() { + if command -v uv >/dev/null 2>&1; then + uv pip install "$@" + else + python -m pip install "$@" + fi +} + +mkdir -p "${INSTALL_ROOT}" + +if [ ! -d "${REPO_DIR}/.git" ]; then + rm -rf "${REPO_DIR}" + git clone "${GORILLA_URL}" "${REPO_DIR}" +fi + +git -C "${REPO_DIR}" fetch --depth 1 origin "${GORILLA_REF}" +git -C "${REPO_DIR}" checkout --force "${GORILLA_REF}" + +pip_install -e "${BFCL_DIR}" +pip_install soundfile diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/browsecompplus/__init__.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/browsecompplus/__init__.py new file mode 100644 index 00000000..367fec41 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/browsecompplus/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/browsecompplus/browsecomp_benchmark.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/browsecompplus/browsecomp_benchmark.py new file mode 100644 index 00000000..b8c4eaaf --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/browsecompplus/browsecomp_benchmark.py @@ -0,0 +1,697 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import datetime +import json +import os +from collections import defaultdict +from collections.abc import Mapping +from pathlib import Path +from typing import TYPE_CHECKING, Any, ClassVar, Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field + +from ...core import Benchmark, Session +from ...core.actions import ActionsHandler +from ...core.evaluator import Evaluator +from ...core.types import ( + Action, + ActionType, + BenchmarkResults, + EmptyObservation, + FinishAction, + MessageAction, + Observation, + SessionIndex, + SessionScore, + SingleAction, + SingleObservation, +) +from ...environment.instance import get_manager +from ...utils.cost import CostReport, LiteLLMCostReport +from ...utils.settings import RunnerName +from .retriever import RetrieverClient, get_retriever_url, get_shared_retriever + +if TYPE_CHECKING: + from .browsecomp_eval import BrowseCompEvaluator + +# Paper-reported total for the full BrowseCompPlus dataset. +DEFAULT_TOTAL_TASKS = 830 + + +class BrowseCompPlusSearchArgs(BaseModel): + query: str + + +class BrowseCompPlusSearchAction(SingleAction): + name: Literal["search"] = "search" + arguments: BrowseCompPlusSearchArgs + + +class BrowseCompPlusGetDocumentsArgs(BaseModel): + docid: str + + +class BrowseCompPlusGetDocAction(SingleAction): + name: Literal["get_document"] = "get_document" + arguments: BrowseCompPlusGetDocumentsArgs + + +class BrowseCompPlusFinishArgs(BaseModel): + exact_answer: str = Field(description="Your succinct, final answer") + explanation: str = Field( + description=( + "Your explanation for your final answer. For this" + " explanation section only, you should cite your" + " evidence documents inline by enclosing their docids" + " in square brackets [] at the end of sentences." + " For example, [20]." + ) + ) + confidence: float = Field(description="Your confidence score between 0% and 100% for your answer") + + +class BrowseCompPlusFinishAction(FinishAction): + name: Literal["submit"] = "submit" + arguments: BrowseCompPlusFinishArgs + + +class BrowseCompPlusSession(Session): + _done: bool + evaluator: BrowseCompEvaluator + + def __init__( + self, + instance: dict[str, Any], + searcher_params: dict[str, Any], + eval_model_id: str = "openai/Azure/gpt-4.1", + max_interactions: int | None = 100, + session_id: str | None = None, + retriever_url: str | None = None, + **_kwargs: Any, + ) -> None: + if session_id is not None: + self._session_id = session_id + self._instance = instance.copy() + self._task_id = instance["task_id"] + self._done = False + self._searcher_params = searcher_params + + # Initialize search: use shared retriever service if URL provided, + # otherwise load the index locally via SearchService singleton. + if retriever_url: + self._init_retriever_client(retriever_url, searcher_params) + else: + self._init_search_tool_handler(searcher_params) + + self._registry = ActionsHandler(logger=self.logger) + self.set_action_types() + self._response = None + self.evaluator = self.get_evaluator(eval_model_id) + self.retrieved_docids = set() + self.tool_call_count = defaultdict(lambda: 0) + self.model_usage = None + self.total_actions_executed = 0 + self.max_interactions = max_interactions + self.logger.info(f"Running for query id {self.task_id}") + super().__init__() + + def _init_retriever_client(self, url: str, searcher_params: dict[str, Any]) -> None: + """Connect to a shared Retriever service by URL.""" + from .search_tool_handler import BCPSearchToolHandler + + client = RetrieverClient(url) + self.search_tool_handler = BCPSearchToolHandler( + searcher=client, + snippet_max_tokens=searcher_params["max_snippet_length"], + k=searcher_params["top_k_docs"], + include_get_document=searcher_params["include_get_document"], + full_doc_max_tokens=searcher_params["full_doc_max_tokens"], + ) + + def _init_search_tool_handler(self, searcher_params: dict[str, Any]) -> None: + """Initialize search tool handler using local singleton service.""" + from searcher.searchers import SearcherType + + from .search_service import get_search_service + from .search_tool_handler import BCPSearchToolHandler + + # Get searcher from singleton service + search_service = get_search_service() + searcher_class = SearcherType.get_searcher_class(searcher_params["searcher_type"]) + + # Extract searcher-specific args + searcher_args = { + k: v + for k, v in searcher_params.items() + if k + not in [ + "searcher_type", + "max_snippet_length", + "top_k_docs", + "include_get_document", + "full_doc_max_tokens", + ] + } + + # Pass session's logger to the service + searcher_obj = search_service.get_or_create_searcher( + searcher_type=searcher_params["searcher_type"], + searcher_class=searcher_class, + logger=self.logger, + **searcher_args, + ) + + # Create search tool handler + self.search_tool_handler = BCPSearchToolHandler( + searcher=searcher_obj, + snippet_max_tokens=searcher_params["max_snippet_length"], + k=searcher_params["top_k_docs"], + include_get_document=searcher_params["include_get_document"], + full_doc_max_tokens=searcher_params["full_doc_max_tokens"], + ) + + @property + def task(self) -> str: + get_doc_str = "and document expansion " if self.search_tool_handler.include_get_document else "" + query = self._instance["query"] + return ( + "Answer the provided question by performing search " + f"{get_doc_str}as needed, and submit your final" + " answer.\n" + f"Question: {query}\n" + "Note:\n" + "- The question has an answer discoverable through" + " proper search.\n" + "- The question requires putting together information" + " from different sources.\n" + "\n" + "Your performance is scored based on:\n" + " 1. Most importantly, the correctness of the" + " answer you assembled from different searches.\n" + " 2. Your effective use of search and your ability" + " to retrieve all relevant information for the" + " question.\n" + " 3. How efficiently you find all the relevant" + " information, using as few searches as possible.\n" + "\n" + "Important: During your work, Do NOT interact with" + " the user or send any messages at any point" + " -- messages will be ignored and are NOT considered" + " a valid final answer. The ONLY acceptable way to" + " finish is by calling 'submit' with the required" + " structured fields.\n" + "\n" + "Finish the session always by calling `submit`." + " If you fail to find the answer, submit with" + ' exact_answer: "Can\'t find the answer.".' + ) + + @property + def context(self) -> dict[str, Any]: + return {} + + @property + def actions(self) -> list[ActionType]: + return self._registry.actions + + @property + def task_id(self) -> str: + """Task identifier.""" + return str(self._instance["query_id"]) + + def _to_observation(self, raw: Any, invoking: list[SingleAction] | None = None) -> Observation: + return SingleObservation(invoking_actions=invoking or [], result=raw) + + def start(self): + return EmptyObservation() + + def record_single_action(self, action: SingleAction) -> None: + self.logger.info(f"Received *{action.name}* action with arguments: {action.arguments}") + self.tool_call_count[action.name] += 1 + self.total_actions_executed += 1 + + def step(self, action: Action) -> Optional[Observation]: + if action is None: + self._done = True + + if self.total_actions_executed >= self.max_interactions: + self.logger.info(f"Reached maximal limit of {self.total_actions_executed} allowed actions") + self._done = True + + if self._done: + return None + + observation = self._registry.execute(action) + return observation + + # Action handlers ------------------------------------------------------------ + def _handle_search(self, action: SingleAction) -> Any: + self.record_single_action(action) + result = self.get_search_result(action) + # keep retrieved docs + self.record_retrieved_docids(result) + return result + + def _handle_get_document(self, action: SingleAction) -> SingleAction | None: + self.record_single_action(action) + args_dict = self.get_arguments_dict(action.arguments) + return self.search_tool_handler.execute_tool("get_document", args_dict) + + def _handle_finish(self, action: SingleAction) -> SingleAction | None: + self.record_single_action(action) + final_response = self.get_arguments_dict(action.arguments) + self._response = json.dumps(final_response) + self._done = True + return None + + def done(self) -> bool: + return self._done + + def score(self) -> SessionScore: + results, self.model_usage = self.evaluator.evaluate_response( + agent_response=self._response, + instance=self._instance, + retrieved_docids_set=self.retrieved_docids, + tool_call_counts=self.tool_call_count, + ) + return results + + def get_cost(self) -> CostReport: + if not self.model_usage: + return LiteLLMCostReport.initialize_empty(model_name=self.evaluator.eval_model_id) + + return LiteLLMCostReport.from_token_counts( + model_name=self.evaluator.eval_model_id, + input_tokens=self.model_usage["prompt_tokens"], + output_tokens=self.model_usage["completion_tokens"], + ) + + def close(self) -> None: + super().close() + # Persist minimal results for aggregation + sc = self.score() + self.save_results(sc.model_dump()) + + def set_action_types(self): + k = self.search_tool_handler.k + n_tokens = self.search_tool_handler.snippet_max_tokens + self._registry.add_action( + name="search", + description=( + "Perform a search on a knowledge source: supply" + " a single 'query' string; the action retrieves" + f" the {k} top most relevant results, each" + f" trimmed to {n_tokens} tokens." + ), + action_cls=BrowseCompPlusSearchAction, + handler=self._handle_search, + ) + self._registry.add_action( + name="submit", + description="Submit final answer and complete", + action_cls=BrowseCompPlusFinishAction, + handler=self._handle_finish, + is_finish=True, + ) + + if self.search_tool_handler.include_get_document: + self._registry.add_action( + name="get_document", + description="Retrieve the full document using its document id", + action_cls=BrowseCompPlusGetDocAction, + handler=self._handle_get_document, + ) + # todo: agents fail in practice without this option, even with explicit instructions. should we keep it? + self._registry.add_action( + name="message", + description="Send the final answer as a message to the user", + action_cls=MessageAction, + handler=self._handle_finish, + is_hidden=True, + is_message=True, + ) + + def get_evaluator(self, eval_model_id): + from .browsecomp_eval import BrowseCompEvaluatorOpenai, BrowsecompEvaluatorQwen + + if "gpt" in eval_model_id: + return BrowseCompEvaluatorOpenai(eval_model_id=eval_model_id) + if eval_model_id == "Qwen/Qwen3-32B": + return BrowsecompEvaluatorQwen() # Currently not supported + raise ValueError(f"Invalid eval_model_id: {eval_model_id}") + + # Robustly extract the answer from pydantic model or dict + def get_arguments_dict(self, args): + if isinstance(args, BaseModel): + return args.model_dump() # Pydantic v2 + if isinstance(args, Mapping): + return dict(args) + return str(getattr(args, "value", {})) + + def record_retrieved_docids(self, result: str): + try: + result = json.loads(result) + retrieved_docids = {result.get("docid") for result in result} + self.retrieved_docids = self.retrieved_docids | retrieved_docids + except json.decoder.JSONDecodeError: + self.logger.error(f"Failed to retrieve docids: {result}") + + def get_search_result(self, act): + from .searcher_cache import SearchDiskCacheSession + + args_dict = self.get_arguments_dict(act.arguments) + searcher_params = self.get_searcher_params() + search_cache = SearchDiskCacheSession(args_dict["query"], **searcher_params) + search_cache.logger = self.logger + result = search_cache.handle_start_fetch_results() + if result: + self.logger.info(f"Retrieved docs from cache: {result}") + else: + result = self.search_tool_handler.execute_tool(act.name, args_dict) + search_cache.cache_results(result) + self.logger.info(f"Retrieved docs from searcher: {result}") + return result + + def get_searcher_params(self): + return { + "n": self.search_tool_handler.snippet_max_tokens, + "k": self.search_tool_handler.k, + "search_type": self._searcher_params.get("searcher_type", "unknown"), + "search_model": self._searcher_params.get("model_name"), + "normalize": self._searcher_params.get("normalize"), + } + + +# ── Evaluator ──────────────────────────────────────────────────────── + + +class BrowseCompPlusEvaluator(Evaluator): + """Evaluator for BrowseCompPlus — task discovery, session kwargs, aggregation.""" + + def __init__( + self, + subset: str = "main", + searcher_type: str = "faiss", + searcher_model_name: str = "Qwen/Qwen3-Embedding-8B", + max_snippet_length: int = 512, + top_k_docs: int = 5, + include_get_document: bool = True, + normalize_search: bool = True, + full_doc_max_tokens: int = 2048, + max_interactions: int | None = 100, + inference_model: str = "N/A", + retriever_url: str | None = None, + eval_model_id: str = "openai/Azure/gpt-4.1", + ) -> None: + self._subset = subset + self._searcher_type = searcher_type + self._searcher_model_name = searcher_model_name + self._max_snippet_length = max_snippet_length + self._top_k_docs = top_k_docs + self._include_get_document = include_get_document + self._normalize_search = normalize_search + self._full_doc_max_tokens = full_doc_max_tokens + self._max_interactions = max_interactions + self._inference_model = inference_model + self._retriever_url = retriever_url + self._eval_model_id = eval_model_id + self._dataset: list[dict[str, Any]] | None = None + self._task_lookup: dict[str, dict[str, Any]] | None = None + + @property + def assets_dir(self): + return get_manager().env_path("benchmarks/browsecompplus") + + def extract_dataset(self): + import pandas as pd + + data_path = self.assets_dir / "data" / "browsecomp_plus_decrypted_docids.jsonl" + if not data_path.exists(): + raise Exception(f"{data_path} does not exist. Run 'exgentic install --benchmark browsecompplus' first.") + instances = pd.read_json(path_or_buf=data_path, lines=True).to_dict(orient="records") + + def proces_instance(instance): + processed_instance = { + "query_id": instance["query_id"], + "query": instance["query"], + "gold_answer": instance["answer"], + } + for k in ["gold_docs", "evidence_docs", "negative_docs"]: + processed_instance[k] = instance[k] + return processed_instance + + instances = [proces_instance(instance) for instance in instances] + return instances + + def _ensure_dataset(self) -> None: + if self._dataset is None: + self._dataset = self.extract_dataset() + self._task_lookup = {str(item["query_id"]): item for item in self._dataset} + + def list_tasks(self) -> list[str]: + self._ensure_dataset() + return [str(item["query_id"]) for item in self._dataset] + + def _get_searcher_params(self) -> dict[str, Any]: + """Get searcher parameters to pass to session.""" + index_dir = self.assets_dir / "indexes" + if self._searcher_type == "bm25": + index_dir = index_dir / "bm25" + searcher_args = {"index_path": str(index_dir)} + else: + model_dir_name = self._searcher_model_name.lower().split("/")[-1] + index_dir = index_dir / model_dir_name + if not os.path.exists(index_dir): + available_models = os.listdir(self.assets_dir / "indexes") + raise FileNotFoundError( + f"Index dir for BrowseCompPlus benchmark {index_dir} does not exist. " + f"Please select an available embedding model out of {available_models}" + ) + index_path = index_dir / "corpus.shard*_of_4.pkl" + searcher_args = { + "index_path": str(index_path), + "model_name": self._searcher_model_name, + "normalize": self._normalize_search, + } + + return { + "searcher_type": self._searcher_type, + "max_snippet_length": self._max_snippet_length, + "top_k_docs": self._top_k_docs, + "include_get_document": self._include_get_document, + "full_doc_max_tokens": self._full_doc_max_tokens, + **searcher_args, + } + + def get_session_kwargs(self, index: SessionIndex) -> dict[str, Any]: + self._ensure_dataset() + task_id = index.task_id + if self._task_lookup is None or task_id not in self._task_lookup: + raise KeyError(f"Unknown BrowseCompPlus task id '{task_id}'.") + instance = {"task_id": task_id, **self._task_lookup[task_id]} + kwargs: dict[str, Any] = { + "instance": instance, + "searcher_params": self._get_searcher_params(), + "max_interactions": self._max_interactions, + "session_id": index.session_id, + "eval_model_id": self._eval_model_id, + } + if self._retriever_url: + kwargs["retriever_url"] = self._retriever_url + return kwargs + + def aggregate_sessions(self, sessions: list[SessionIndex]) -> BenchmarkResults: + # Aggregate per-session scores written by sessions + scores: list[float] = [] + retrieval_recalls: list[float] = [] + confidence_list: list[float] = [] + tool_call_counts_list: list[dict[str, float]] = [] + correctness: list[float] = [] + for paths in self.get_sessions_paths(sessions): + fp = paths.benchmark_results + if not fp.exists(): + raise FileNotFoundError(f"Missing results for planned session '{paths.session_id}' at {fp}") + + with open(fp, encoding="utf-8-sig") as f: + payload = json.load(f) + if not payload: + raise ValueError(f"Empty benchmark results for session '{paths.session_id}' at {fp}") + + s = float(payload["score"]) # minimal: assume exists + scores.append(s) + metrics = payload.get("session_metrics", {}) + if not metrics: + print(f"No metrics for session '{paths.session_id}' at {fp}") + + retrieval_recall = metrics.get("Retrieval_recall", 0) + retrieval_recalls.append(float(retrieval_recall) if retrieval_recall is not None else 0) + correctness.append(payload.get("success", 0)) + + confidence = metrics.get("Confidence") + try: + confidence = float(confidence) + except Exception: + confidence = 0 + confidence_list.append(confidence) + + metadata = payload.get("session_metadata", {}) + if not metadata: + print(f"No metadata for session '{paths.session_id}' at {fp}") + tool_call_counts_list.append(metadata.get("tool_call_counts", {})) + + avg = sum(scores) / len(scores) if scores else 0.0 + avg_retrieval_recalls = sum(retrieval_recalls) / len(retrieval_recalls) if retrieval_recalls else 0.0 + tools_keys = set().union(*tool_call_counts_list) + avg_tool_use_counts = { + k: sum(d.get(k, 0) for d in tool_call_counts_list) / len(tool_call_counts_list) for k in tools_keys + } + + calibration_error = None + # calibration error only comupted for a large number of examples + if len(correctness) >= 100: + try: + from scripts_evaluation.evaluate_with_openai import calculate_calibration_error + + calibration_error = calculate_calibration_error(confidences=confidence_list, correctness=correctness) + except Exception: + print(f"Failed to calculate calibration error for session '{paths.session_id}' at {fp}") + calibration_error = 0 + metrics = { + "LLM": self._inference_model, + "Accuracy (%)": avg, + "Recall (%)": avg_retrieval_recalls, + "avg_tool_stats": avg_tool_use_counts, + "Calibration Error (%)": calibration_error, + "Retriever": self._searcher_model_name, + "Link": "change me when submitting", + "Evaluation Date": datetime.datetime.now().date().isoformat(), + } + return BenchmarkResults( + benchmark_name="BrowseCompPlus", + total_tasks=len(sessions), + score=avg, + metrics=metrics, + ) + + +# ── Benchmark config ───────────────────────────────────────────────── + + +class BrowseCompPlusBenchmark(Benchmark, BaseModel): + display_name: ClassVar[str] = "BrowseCompPlus" + slug_name: ClassVar[str] = "browsecompplus" + model_config = ConfigDict(arbitrary_types_allowed=True) + + @classmethod + def _get_evaluator_class(cls): + return BrowseCompPlusEvaluator + + @classmethod + def _get_session_class(cls): + return BrowseCompPlusSession + + subset: Literal["main"] = "main" + + runner: RunnerName | None = None # Threadsafe; uses global default runner (venv) + + # Retriever runner — when set, the search index runs as a shared service + # instead of being loaded in each session process. Useful when sessions + # run in Docker to avoid duplicating the heavy index in RAM. + retriever_runner: RunnerName | None = None + + # Agent inference params (for logging) + inference_model: str = "N/A" + + # searcher params + searcher_type: str = "faiss" # "bm25" or "faiss" + searcher_model_name: str = "Qwen/Qwen3-Embedding-8B" # Used for faiss only + max_snippet_length: int = 512 + top_k_docs: int = 5 + include_get_document: bool = True + normalize_search: bool = True + full_doc_max_tokens: int = 2048 + max_interactions: int | None = 100 + eval_model_id: str = "openai/Azure/gpt-4.1" + + @property + def _assets_dir(self) -> str: + return str(get_manager().env_path("benchmarks/browsecompplus")) + + def _retriever_runner_kwargs(self) -> dict[str, Any]: + """Runner kwargs for the retriever container (volumes). + + Only returns Docker-specific kwargs when the retriever actually runs + in Docker; for 'service' or 'direct' these would leak into the + target class constructor and cause errors. + """ + if self.retriever_runner != "docker": + return {} + kw: dict[str, Any] = { + "env_name": f"benchmarks/{self.slug_name}", + "module_path": type(self).__module__, + } + kw["volumes"] = {self._assets_dir: self._assets_dir} + return kw + + def _get_retriever_searcher_args(self) -> dict[str, Any]: + """Searcher constructor args for the Retriever.""" + index_dir = Path(self._assets_dir) / "indexes" + if self.searcher_type == "bm25": + return {"index_path": str(index_dir / "bm25")} + model_dir = self.searcher_model_name.lower().split("/")[-1] + return { + "index_path": str(index_dir / model_dir / "corpus.shard*_of_4.pkl"), + "model_name": self.searcher_model_name, + "normalize": self.normalize_search, + } + + def _ensure_retriever(self) -> str: + """Start a shared retriever service and return its URL.""" + assert self.retriever_runner is not None + proxy = get_shared_retriever( + runner=self.retriever_runner, + runner_kwargs=self._retriever_runner_kwargs(), + searcher_type=self.searcher_type, + **self._get_retriever_searcher_args(), + ) + url = get_retriever_url(proxy) + # Rewrite URL for Docker sessions so they can reach the host. + if self.resolve_runner() == "docker": + url = url.replace("127.0.0.1", "host.docker.internal") + return url + + def runner_kwargs(self) -> dict[str, Any]: + kw = super().runner_kwargs() + kw["health_timeout"] = 120.0 + if self.resolve_runner() == "docker": + # Mount host assets (indexes, data) into the container so they + # are not re-downloaded for every image build. + volumes = kw.get("volumes", {}) + volumes[self._assets_dir] = self._assets_dir + kw["volumes"] = volumes + return kw + + def _get_evaluator_kwargs(self) -> dict[str, Any]: + kwargs: dict[str, Any] = { + "subset": self.subset, + "searcher_type": self.searcher_type, + "searcher_model_name": self.searcher_model_name, + "max_snippet_length": self.max_snippet_length, + "top_k_docs": self.top_k_docs, + "include_get_document": self.include_get_document, + "normalize_search": self.normalize_search, + "full_doc_max_tokens": self.full_doc_max_tokens, + "max_interactions": self.max_interactions, + "inference_model": self.inference_model, + "eval_model_id": self.eval_model_id, + } + # Auto-use a shared retriever service for Docker so that session + # containers don't each load the heavy search index (OOM). + if not self.retriever_runner and self.resolve_runner() == "docker": + self.retriever_runner = "service" + if self.retriever_runner: + kwargs["retriever_url"] = self._ensure_retriever() + return kwargs diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/browsecompplus/browsecomp_eval.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/browsecompplus/browsecomp_eval.py new file mode 100644 index 00000000..bd67e3c5 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/browsecompplus/browsecomp_eval.py @@ -0,0 +1,156 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import json +from typing import Any + +import litellm +from pydantic import BaseModel +from scripts_evaluation.evaluate_with_openai import ( + GRADER_TEMPLATE as GRADER_TEMPLATE_OPENAI, +) +from scripts_evaluation.evaluate_with_openai import ( + compute_citation_metrics, + extract_citations_from_response, + parse_judge_response, +) +from search_agent.prompts import GRADER_TEMPLATE_QWEN + +from ...core.context import try_get_context +from ...core.types import SessionScore +from ...utils.settings import get_settings + +_settings = get_settings() + + +class BrowseCompEvaluator(BaseModel): + eval_model_id: str + sampling_params: dict[str, Any] = {} + grader_template: str + + def evaluate_response( + self, + agent_response, + instance, + retrieved_docids_set=None, + tool_call_counts=None, + ) -> SessionScore: + question = instance["query"] + correct_answer = instance["gold_answer"] + positives_for_query = instance["evidence_docs"] + cited_docids = [] + confidence = None + retrieval_recall = None + extracted_final_answer = None + judge_textual_response = None + + # compute retrieval recall + if retrieved_docids_set is not None: + retrieval_recall = len(retrieved_docids_set.intersection(set(positives_for_query))) / float( + len(positives_for_query) + ) + + if not agent_response: # run was halted without a final answer + is_successful = False + score = 0 + is_complete = False + parse_error = False + judge_usage = None + + else: # call judge + is_complete = True + prompt = self.create_judge_prompt(question, agent_response, correct_answer) + judge_response = litellm.completion( + model=self.eval_model_id, + messages=[{"role": "user", "content": prompt}], + max_tokens=self.max_output_tokens, + litellm_metadata={"context": try_get_context()}, + **self.sampling_params, + ) + choice = judge_response["choices"][0] + judge_textual_response = choice.get("message").content + judge_usage = judge_response.usage.copy() + answer_metrics = parse_judge_response(judge_textual_response) + parse_error = bool(answer_metrics["parse_error"]) + + if parse_error: + is_successful = False + score = 0 + else: + is_successful = answer_metrics["correct"] + score = int(is_successful) if is_successful is not None else 0 + confidence = (answer_metrics.get("confidence", 100),) + extracted_final_answer = answer_metrics.get("extracted_final_answer") + cited_docids = extract_citations_from_response(agent_response) + + citation_metrics_positives = compute_citation_metrics(cited_docids, positives_for_query) + scores = { + "Accuracy": score, + "Retrieval_recall": retrieval_recall, + "Citation_metrics_positives": citation_metrics_positives, + "Confidence": confidence, + } + meta_data = { + "instance": instance.copy(), + "retrieved_docids": list(retrieved_docids_set) if retrieved_docids_set else [], + "response": agent_response, + "extracted_final_answer": extracted_final_answer, + "judge_model": self.eval_model_id, + "is_complete": is_complete, + "judge_parse_error": parse_error, + "tool_call_counts": tool_call_counts, + } + if parse_error: + meta_data["judge_raw_response"] = judge_textual_response + all_scores = SessionScore( + score=int(is_successful), + success=is_successful, + is_finished=is_complete, + session_metrics=scores, + session_metadata=meta_data, + ) + + return all_scores, judge_usage + + def create_judge_prompt(self, question: str, response: str, correct_answer: str) -> str: + return self.grader_template.format(question=question, response=response, correct_answer=correct_answer) + + +class BrowseCompEvaluatorOpenai(BrowseCompEvaluator): + max_output_tokens: int = 1024 + grader_template: str = GRADER_TEMPLATE_OPENAI + eval_model_id: str = "openai/Azure/gpt-4.1" + + +class BrowsecompEvaluatorQwen(BrowseCompEvaluator): + max_output_tokens: int = 4096 + temperature: int = 0.7 + top_p: float = 0.8 + top_k: int = 20 + eval_model_id: str = "Qwen/Qwen3-32B" + + def model_post_init(self, __context) -> None: + self.grader_template = GRADER_TEMPLATE_QWEN + self.sampling_params = { + "top_p": self.top_p, + "temperature": self.temperature, + "top_k": self.top_k, + } + + +if __name__ == "__main__": + e = BrowseCompEvaluatorOpenai() # eval_model_id="watsonx/openai/gpt-oss-120b") + instance = { + "gold_answer": "2015", + "query": "When did someone was born", + "evidence_docs": ["1", "4"], + } + response = { + "explanation": "as stated in docs [1] [5]", + "exact_answer": "1925", + "confidence": 0.8, + } + response = json.dumps(response) + + r = e.evaluate_response(response, instance) + print(r[0]) diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/browsecompplus/make_light_dataset.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/browsecompplus/make_light_dataset.py new file mode 100644 index 00000000..db01af1c --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/browsecompplus/make_light_dataset.py @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import argparse +import json + + +def main(): + p = argparse.ArgumentParser(description="Create a light JSONL with only docids.") + p.add_argument("--input", required=True, help="Path to full JSONL") + p.add_argument("--output", required=True, help="Path to write the light JSONL") + args = p.parse_args() + + with open(args.input, encoding="utf-8") as fin, open(args.output, "w", encoding="utf-8") as fout: + for line in fin: + if line.strip(): + obj = json.loads(line) + for k in ["gold_docs", "evidence_docs", "negative_docs"]: + obj[k] = [d.get("docid") for d in obj.get(k) if d.get("docid") is not None] + + fout.write(json.dumps(obj, ensure_ascii=False) + "\n") + + +if __name__ == "__main__": + main() diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/browsecompplus/requirements.txt b/labs/AgentStream/exgentic/src/exgentic/benchmarks/browsecompplus/requirements.txt new file mode 100644 index 00000000..3fc1116e --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/browsecompplus/requirements.txt @@ -0,0 +1,2 @@ +# browsecomp-plus is installed by setup.sh rather than here because it pins +# conflicting versions of fastmcp, mcp, and pydantic. diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/browsecompplus/retriever.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/browsecompplus/retriever.py new file mode 100644 index 00000000..69d4ff8e --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/browsecompplus/retriever.py @@ -0,0 +1,133 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Shared retriever service for BrowseCompPlus search index. + +The Retriever loads the heavy search index once and serves queries. +It can run via any runner (direct, service, docker, etc.), allowing +a single index copy to be shared across all sessions. +""" + +import argparse +import json +import threading +from typing import Any + + +class Retriever: + """Loads a search index and serves queries. + + Designed to run via ``with_runner()`` in any runner. + """ + + def __init__(self, searcher_type: str, **searcher_args: Any) -> None: + # Import torch/safetensors before the searcher module to avoid a + # native-library initialisation conflict with FAISS that causes + # segfaults on Apple Silicon (faiss_searcher.py imports faiss + # before torch at module level). + import safetensors # noqa: F401 + import torch # noqa: F401 + from searcher.searchers import SearcherType + + searcher_class = SearcherType.get_searcher_class(searcher_type) + parser = argparse.ArgumentParser() + searcher_class.parse_args(parser) + + cli: list[str] = [] + for key, value in searcher_args.items(): + flag = f"--{key.replace('_', '-')}" + if isinstance(value, bool): + if value: + cli.append(flag) + else: + cli.extend([flag, str(value)]) + + args = parser.parse_args(cli) + self._searcher = searcher_class(args) + + def search(self, query: str, k: int) -> list: + return self._searcher.search(query, k) + + def get_document(self, docid: str) -> dict | None: + return self._searcher.get_document(docid) + + +class RetrieverClient: + """Lazy HTTP client to a remote Retriever service. + + Picklable — stores only the URL. Connects on first use. + This allows it to survive serialization into Docker containers. + """ + + def __init__(self, url: str) -> None: + self._url = url + self._proxy: Any = None + + def _connect(self) -> None: + if self._proxy is None: + from ...adapters.runners.service import HTTPTransport + from ...adapters.runners.transport import ObjectProxy + + self._proxy = ObjectProxy(HTTPTransport(self._url)) + + def search(self, query: str, k: int) -> list: + self._connect() + return self._proxy.search(query, k) + + def get_document(self, docid: str) -> dict | None: + self._connect() + return self._proxy.get_document(docid) + + def close(self) -> None: + if self._proxy is not None: + try: + self._proxy.close() + except Exception: + pass + self._proxy = None + + def __getstate__(self) -> dict: + return {"url": self._url} + + def __setstate__(self, state: dict) -> None: + self._url = state["url"] + self._proxy = None + + +# ── Shared retriever cache ──────────────────────────────────────────── + +_cache_lock = threading.Lock() +_cache: dict[str, Any] = {} + + +def get_shared_retriever( + runner: str, + runner_kwargs: dict[str, Any] | None = None, + **retriever_kwargs: Any, +) -> Any: + """Get or create a shared Retriever running in the specified runner.""" + from ...adapters.runners import with_runner + + key = json.dumps(retriever_kwargs, sort_keys=True, default=str) + if key not in _cache: + with _cache_lock: + if key not in _cache: + _cache[key] = with_runner( + Retriever, + runner=runner, + **(runner_kwargs or {}), + **retriever_kwargs, + ) + return _cache[key] + + +def get_retriever_url(proxy: Any) -> str: + """Extract the HTTP URL from a retriever proxy.""" + from ...adapters.runners.service import HTTPTransport + + transport = object.__getattribute__(proxy, "_transport") + if isinstance(transport, HTTPTransport): + return transport._base_url + raise ValueError( + "Cannot extract URL from non-HTTP retriever. Use runner='service' or runner='docker' for the retriever." + ) diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/browsecompplus/search_service.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/browsecompplus/search_service.py new file mode 100644 index 00000000..d5bfbbd1 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/browsecompplus/search_service.py @@ -0,0 +1,173 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Thread-safe search service with semaphore for concurrency control.""" + +import argparse +import atexit +import json +import logging +import os +import threading +import time + +import psutil + + +class SearchService: + """Thread-safe singleton service for managing searcher instances.""" + + _instance = None + _instance_lock = threading.Lock() + + def __new__(cls): + if cls._instance is None: + with cls._instance_lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._searchers = {} + cls._instance._cache_lock = threading.RLock() + cls._instance._search_semaphore = threading.Semaphore(5) + cls._instance._shutdown = False + # Register cleanup to run when program exits + atexit.register(cls._instance.shutdown) + return cls._instance + + def shutdown(self): + """Clean up all searcher instances and their resources.""" + with self._cache_lock: + if self._shutdown: + return + self._shutdown = True + + logger = logging.getLogger(__name__) + logger.info("Shutting down SearchService and cleaning up models...") + + for cache_key, searcher in self._searchers.items(): + try: + # Try to close/cleanup the searcher if it has such methods + if hasattr(searcher, "close"): + searcher.close() + elif hasattr(searcher, "shutdown"): + searcher.shutdown() + + if hasattr(searcher, "searcher") and hasattr(searcher.searcher, "close"): + searcher.searcher.close() + + except Exception as e: + logger.warning(f"Error cleaning up searcher {cache_key}: {e}") + + self._searchers.clear() + logger.info("SearchService shutdown complete") + + def get_or_create_searcher( + self, + searcher_type: str, + searcher_class: type, + logger: logging.Logger | None = None, + **searcher_args, + ): + """Get or create a searcher instance with thread-safe access.""" + if logger is None: + logger = logging.getLogger(__name__) + + # Create cache key + args_str = json.dumps(searcher_args, sort_keys=True, default=str) + cache_key = f"{searcher_type}:{args_str}" + + # Check if already cached + if cache_key in self._searchers: + logger.info( + f"Reusing cached searcher: {searcher_type} (PID: {os.getpid()}, Thread: {threading.get_ident()})" + ) + return ThreadSafeSearcherWrapper(self._searchers[cache_key], self._search_semaphore) + + # Create new searcher (with lock) + with self._cache_lock: + if cache_key in self._searchers: + return ThreadSafeSearcherWrapper(self._searchers[cache_key], self._search_semaphore) + + process = psutil.Process(os.getpid()) + mem_before = process.memory_info().rss / 1024 / 1024 + thread_id = threading.get_ident() + logger.info( + f"Loading searcher model (before: {mem_before:.1f} MB, PID: {os.getpid()}, Thread: {thread_id})" + ) + + # Instantiate searcher + searcher = self._instantiate_with_overrides(searcher_class, **searcher_args) + self._searchers[cache_key] = searcher + + mem_after = process.memory_info().rss / 1024 / 1024 + logger.info( + f"Searcher model loaded: {searcher_type} " + f"(after: {mem_after:.1f} MB, delta: {mem_after - mem_before:.1f} MB)" + ) + + return ThreadSafeSearcherWrapper(searcher, self._search_semaphore) + + @staticmethod + def _instantiate_with_overrides(cls, **overrides): + """Instantiate a class that uses argparse for configuration.""" + parser = argparse.ArgumentParser() + cls.parse_args(parser) + + cli = [] + for key, value in overrides.items(): + flag = f"--{key.replace('_', '-')}" + if isinstance(value, bool): + if value: + cli.append(flag) + else: + cli.extend([flag, str(value)]) + + args = parser.parse_args(cli) + return cls(args) + + +class ThreadSafeSearcherWrapper: + """Wrapper that limits concurrent access to the searcher.""" + + def __init__(self, searcher, semaphore): + self._searcher = searcher + self._semaphore = semaphore + self._search_count = 0 + + def search(self, query: str, k: int): + """Thread-safe search with semaphore and lock for async safety.""" + # Use both semaphore and lock to ensure serialization even in async contexts + acquired = self._semaphore.acquire(blocking=True, timeout=300) + if not acquired: + raise TimeoutError("Failed to acquire semaphore for search") + try: + self._search_count += 1 + count = self._search_count + + logger = logging.getLogger(__name__) + logger.debug(f"Search #{count} starting (thread={threading.get_ident()})") + + result = self._searcher.search(query, k) + time.sleep(0.01) + logger.debug(f"Search #{count} completed") + return result + finally: + self._semaphore.release() + + def get_document(self, docid: str): + """Thread-safe document retrieval with semaphore and lock.""" + acquired = self._semaphore.acquire(blocking=True, timeout=300) + if not acquired: + raise TimeoutError("Failed to acquire semaphore for get_document") + try: + return self._searcher.get_document(docid) + finally: + self._semaphore.release() + + def __getattr__(self, name): + """Forward other attributes to the wrapped searcher.""" + return getattr(self._searcher, name) + + +def get_search_service() -> SearchService: + """Get the singleton SearchService instance.""" + return SearchService() diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/browsecompplus/search_tool_handler.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/browsecompplus/search_tool_handler.py new file mode 100644 index 00000000..d4ef5f27 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/browsecompplus/search_tool_handler.py @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import json + +from transformers import AutoTokenizer + + +class BCPSearchToolHandler: + def __init__( + self, + searcher, + snippet_max_tokens: int | None = None, + k: int = 5, + include_get_document: bool = True, + full_doc_max_tokens: int | None = None, + ): + self.searcher = searcher + self.snippet_max_tokens = snippet_max_tokens + self.k = k + self.include_get_document = include_get_document + + self.tokenizer = None + self.full_doc_max_tokens = None + if snippet_max_tokens and snippet_max_tokens > 0: + self.tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen3-0.6B") + self.full_doc_max_tokens = full_doc_max_tokens + + def execute_tool(self, tool_name: str, arguments: dict): + if tool_name == "search": + return self._search(arguments["query"]) + if tool_name == "get_document": + return self._get_document(arguments["docid"]) + raise ValueError(f"Unknown tool: {tool_name}") + + def _search(self, query: str): + candidates = self.searcher.search(query, self.k) + + if self.snippet_max_tokens and self.snippet_max_tokens > 0 and self.tokenizer: + for cand in candidates: + text = cand["text"] + cand["snippet"] = self._truncate_text(text, self.snippet_max_tokens) + else: + for cand in candidates: + cand["snippet"] = cand["text"] + + results = [] + for cand in candidates: + if cand.get("score") is None: + results.append({"docid": cand["docid"], "snippet": cand["snippet"]}) + else: + results.append( + { + "docid": cand["docid"], + "score": cand["score"], + "snippet": cand["snippet"], + } + ) + + return json.dumps(results, indent=2) + + def _get_document(self, docid: str): + try: + result = self.searcher.get_document(docid) + except Exception: + result = None + if result is None: + return json.dumps({"error": f"Document {docid} not found"}) + + text = result.get("text") + result["text"] = self._truncate_text(text, max_len=self.full_doc_max_tokens) + return json.dumps(result, indent=2) + + def _truncate_text(self, text: str, max_len: int) -> str: + if not self.tokenizer: + raise RuntimeError("Tokenizer not initialized") + tokens = self.tokenizer.encode(text, add_special_tokens=False) + if max_len and len(tokens) > max_len: + truncated_tokens = tokens[:max_len] + return self.tokenizer.decode(truncated_tokens, skip_special_tokens=True) + return text diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/browsecompplus/searcher_cache.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/browsecompplus/searcher_cache.py new file mode 100644 index 00000000..86419cd3 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/browsecompplus/searcher_cache.py @@ -0,0 +1,69 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import hashlib +from typing import Any + +from ...utils.disk_cache import DiskCacheSessionMixin + + +class SearchDiskCacheSession(DiskCacheSessionMixin): + CACHE_DIR = "./exgentic_session_cache/browsecomp/searcher" # single DB for all runs + + def __init__( + self, + query: str, + n: int, + k: int, + search_type: str, + search_model: str, + normalize: bool = False, + use_cache: bool = True, + ): + self.query = query + self.n = n + self.k = k + self.search_type = search_type + self.search_model = search_model + self.normalize = normalize + self._init_cache_mixin(use_cache=use_cache) + + def build_cache_key_payload(self) -> dict: + return { + "search_type": self.search_type, + "search_model": self.search_model, + "normalize": self.normalize, + "n": self.n, + "k": self.k, + "q": hashlib.sha256(self.query.encode("utf-8")).hexdigest(), + } + + def build_additional_cache_metadata(self) -> dict: + return { + "search_type": self.search_type, + "search_model": self.search_model, + "normalize": self.normalize, + } + + def on_cache_hit(self, payload: dict[str, Any]) -> bool: + results = payload.get("results") + if not isinstance(results, dict): + return False + result = results.get("raw") + if result is None or not isinstance(result, str): + return False + return True + + def cache_results(self, result: str): + self.set_results_payload({"raw": result}) + self.cache_score({}) + + def handle_start_fetch_results(self): + if self.handle_cache_start(): + cache_results = self.get_results_payload() + if cache_results: + try: + return cache_results["raw"] + except Exception: + return None + return None diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/browsecompplus/setup.sh b/labs/AgentStream/exgentic/src/exgentic/benchmarks/browsecompplus/setup.sh new file mode 100644 index 00000000..3e902651 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/browsecompplus/setup.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -euo pipefail + +############################################################################### +# 1. Detect java in PATH +############################################################################### +BM25_AVAILABLE=false +if command -v java >/dev/null 2>&1; then + JAVA_VERSION_RAW="$(java -version 2>&1 | head -n1)" || true + JAVA_MAJOR="$(echo "$JAVA_VERSION_RAW" | sed -E 's/.*"([0-9]+).*/\1/')" + if [[ "$JAVA_MAJOR" =~ ^[0-9]+$ ]] && [ "$JAVA_MAJOR" -ge 21 ]; then + BM25_AVAILABLE=true + else + echo "[WARNING] Java 21+ not detected. BM25 searcher will not be available." + fi +else + echo "[WARNING] Java not found. BM25 searcher will not be available." +fi + +BENCH_ROOT="." + +############################################################################### +# 2. Detect GPU availability +############################################################################### +GPU_AVAILABLE=false +if command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi >/dev/null 2>&1; then + GPU_AVAILABLE=true +fi + +############################################################################### +# 3. Install BrowseCompPlus packages +############################################################################### +GIT_SSH_URL="https://github.com/lilacheden/BrowseComp-Plus/" +GIT_REF="mac-support-and-packaging" + +if [ "$GPU_AVAILABLE" = true ]; then + uv pip install "git+${GIT_SSH_URL}@${GIT_REF}#egg=browsecomp-plus[gpu]" +else + uv pip install "git+${GIT_SSH_URL}@${GIT_REF}" +fi + +uv pip install --upgrade "mcp>=1.24" "transformers>=4.53.2,<5.0" \ + "pillow>=12.1.1" "fastmcp>=2.14.0" "fastapi-sso>=0.19.0" "openai>=2.9.0" +uv pip uninstall gradio 2>/dev/null || true + +if [ "$GPU_AVAILABLE" = true ]; then + uv pip install --no-build-isolation flash-attn +fi + +# In Docker builds, skip data/index downloads — they'll be mounted as volumes. +if [ "${EXGENTIC_DOCKER_BUILD:-}" = "1" ]; then + echo "Docker build: skipping data and index downloads (will be mounted at runtime)" + exit 0 +fi + +############################################################################### +# 4. Download + decrypt dataset +############################################################################### +DATA_DIR="${BENCH_ROOT}/data" +QUERIES_DIR="${BENCH_ROOT}/topics-qrels" +mkdir -p "${DATA_DIR}" "${QUERIES_DIR}" + +if [ -f "${DATA_DIR}/browsecomp_plus_decrypted.jsonl" ] && [ -f "${QUERIES_DIR}/queries.tsv" ]; then + echo "Dataset already exists, skipping download." +else + python -m scripts_build_index.decrypt_dataset \ + --output "${DATA_DIR}/browsecomp_plus_decrypted.jsonl" \ + --generate-tsv "${QUERIES_DIR}/queries.tsv" +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIGHT_JSONL="${DATA_DIR}/browsecomp_plus_decrypted_docids.jsonl" +if [ ! -f "${LIGHT_JSONL}" ]; then + PYTHONPATH="${SCRIPT_DIR}:${PYTHONPATH:-}" python -m make_light_dataset \ + --input "${DATA_DIR}/browsecomp_plus_decrypted.jsonl" \ + --output "${LIGHT_JSONL}" +fi + +############################################################################### +# 5. Download indexes +############################################################################### +uv pip install -U hf_transfer 2>/dev/null || true + +mkdir -p "${BENCH_ROOT}/indexes" +cd "${BENCH_ROOT}/indexes" + +if [ "$BM25_AVAILABLE" = true ]; then + HF_HUB_ENABLE_HF_TRANSFER=1 hf download Tevatron/browsecomp-plus-indexes --repo-type=dataset --include="bm25/*" --local-dir . +fi + +HF_HUB_ENABLE_HF_TRANSFER=1 hf download Tevatron/browsecomp-plus-indexes --repo-type=dataset --include="qwen3-embedding-8b/*" --local-dir . + +echo "BrowseCompPlus setup complete" diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/gsm8k/__init__.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/gsm8k/__init__.py new file mode 100644 index 00000000..367fec41 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/gsm8k/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/gsm8k/gsm8k_benchmark.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/gsm8k/gsm8k_benchmark.py new file mode 100644 index 00000000..868e4798 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/gsm8k/gsm8k_benchmark.py @@ -0,0 +1,351 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import ast +import json +import logging +import operator as op +import re +from typing import Any, ClassVar, Literal + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + field_validator, +) + +from ...core.actions import ActionsHandler, extract_argument +from ...core.benchmark import Benchmark +from ...core.evaluator import Evaluator +from ...core.session import Session +from ...core.types import ( + Action, + ActionType, + BenchmarkResults, + EmptyObservation, + FinishAction, + Observation, + SessionIndex, + SessionScore, + SingleAction, + SingleObservation, +) +from ...observers.logging import get_logger +from ...utils.paths import get_run_paths +from ...utils.settings import ExgenticSettings, RunnerName, get_settings + +GSM8K_TOTAL_TASKS = 1319 + +_run_logger: logging.Logger | None = None + + +def _get_run_logger() -> logging.Logger: + """Benchmark-level logger that writes into the run's run log.""" + global _run_logger + if _run_logger is None: + log_path = get_run_paths().tracker + _run_logger = get_logger(__name__, str(log_path)) + return _run_logger + + +def _parse_int(s: str | None) -> int | None: + if s is None: + return None + s = str(s).strip() + # allow "42\n" etc. + if re.fullmatch(r"[+-]?\d+", s): + return int(s) + return None + + +_ALLOWED_BINOPS = { + ast.Add: op.add, + ast.Sub: op.sub, + ast.Mult: op.mul, + ast.Div: op.truediv, +} +_ALLOWED_UNARYOPS = {ast.UAdd: op.pos, ast.USub: op.neg} +_ALLOWED_DESC = "numbers (ints/decimals), + - * /, parentheses, unary +/-. No names, functions, **, %, comparisons." + + +def safe_evaluate(expression: str): + expr = (expression or "").strip() + if not expr: + return f"Invalid expression: empty. Allowed: {_ALLOWED_DESC} Got: {expression!r}" + try: + tree = ast.parse(expr, mode="eval") + except SyntaxError: + return f"Invalid syntax. Allowed: {_ALLOWED_DESC} Got: {expression!r}" + + def ev(n): + if isinstance(n, ast.Expression): + return ev(n.body) + if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): + return n.value + if isinstance(n, ast.UnaryOp) and type(n.op) in _ALLOWED_UNARYOPS: + return _ALLOWED_UNARYOPS[type(n.op)](ev(n.operand)) + if isinstance(n, ast.BinOp) and type(n.op) in _ALLOWED_BINOPS: + if isinstance(n.op, ast.Div) and ev(n.right) == 0: + return f"Invalid op: division by zero. Got: {expression!r}" + return _ALLOWED_BINOPS[type(n.op)](ev(n.left), ev(n.right)) + return f"Invalid element: {type(n).__name__}. Allowed: {_ALLOWED_DESC} Got: {expression!r}" + + try: + out = ev(tree) + return ( + out + if isinstance(out, (int, float, str)) + else f"Did not evaluate to a number. Allowed: {_ALLOWED_DESC} Got: {expression!r}" + ) + except Exception as e: + return f"Error evaluating. Allowed: {_ALLOWED_DESC} Got: {expression!r}. Error: {type(e).__name__}: {e}" + + +class GSM8kCalculateExpressionArgs(BaseModel): + expression: str = Field(..., description="Arithmetic expression using + - * / and parentheses.") + + +class GSM8kCalculateExpressionAction(SingleAction): + name: Literal["calculate_expression"] = "calculate_expression" + arguments: GSM8kCalculateExpressionArgs + + +class GSM8kFinishArgs(BaseModel): + answer: str | int = Field(..., description="Final answer as a single integer (string or int).") + + @field_validator("answer", mode="before") + @classmethod + def coerce_int_to_str(cls, v: Any) -> str: + # Allow agents to pass raw integers; store as string for downstream checks. + if isinstance(v, int): + return str(v) + return v + + @field_validator("answer") + @classmethod + def must_look_like_int(cls, v: str) -> str: + v = v.strip() + if not re.fullmatch(r"[+-]?\d+", v): + raise ValueError("Answer must be a single integer.") + return v + + +class GSM8kFinishAction(FinishAction): + name: Literal["submit"] = "submit" + arguments: GSM8kFinishArgs + + +class GSM8kSession(Session): + """Session for GSM8k benchmark evaluation.""" + + _question: str + _done: bool + + def __init__( + self, + settings: ExgenticSettings, + include_calculator_tool: bool, + instance: dict[str, Any], + session_id: str | None = None, + ) -> None: + if session_id is not None: + self._session_id = session_id + self._question = instance["question"] + self._answer = instance["answer"] + self._task_id = instance["task_id"] + self._done = False + self._gold_answer = self._answer.split("####")[-1].strip() + self._final_answer = None + self._registry = ActionsHandler(logger=self.logger) + # Define Actions directly (single source of truth) + + if include_calculator_tool: + self._registry.add_action( + name="calculate_expression", + description=( + "Evaluate a mathematical expression using only" + " numbers and basic operators" + " (+, -, *, /, parentheses)." + ), + action_cls=GSM8kCalculateExpressionAction, + handler=self._handle_calculate_expression, + ) + + self._registry.add_action( + name="submit", + description="Submit final answer and complete the task.", + action_cls=GSM8kFinishAction, + handler=self._handle_finish, + is_finish=True, + ) + super().__init__() + + @property + def task(self) -> str: + return ( + "Solve the following math word problem using basic arithmetic.\n" + "You may perform intermediate calculations if helpful.\n" + "When you are finished, submit the final answer as a single integer by calling `submit`.\n" + "\n" + "Do not include units, words, or explanations in the final answer.\n" + "Your response will be graded only on whether the final integer exactly matches the correct answer.\n" + "\n" + f"Question:\n\n{self._question}" + ) + + @property + def context(self) -> dict[str, Any]: + return {} + + @property + def actions(self) -> list[ActionType]: + return self._registry.actions + + @property + def task_id(self) -> str: + return str(self._task_id) + + def _to_observation(self, raw: Any, invoking_actions: list[SingleAction] | None = None) -> Observation: + return SingleObservation(invoking_actions=invoking_actions or [], result=raw) + + def start(self) -> Observation | None: + # Empty initial observation; question is carried in the task string. + return EmptyObservation() + + def step(self, action: Action) -> Observation | None: + if action is None: + self._done = True + + if self._done: + return None + + observation = self._registry.execute(action) + + return observation + + def done(self) -> bool: + return self._done + + def score(self) -> SessionScore: + gold = _parse_int(self._gold_answer) + pred = _parse_int(self._final_answer) + score = 1.0 if (gold is not None and pred is not None and gold == pred) else 0.0 + self.logger.info(f"Gold: {self._gold_answer} Prediction: {self._final_answer} Score: {score}") + # Finished only when the benchmark finish action stores a final answer. + finished = self._final_answer is not None + success = score == 1.0 + return SessionScore(score=float(score), success=success, is_finished=finished) + + def close(self): + super().close() + # Persist minimal results for aggregation + sc = self.score() + self.save_standard_results(sc) + + # Action handlers ------------------------------------------------------------ + def _handle_calculate_expression(self, action: SingleAction) -> Any: + self.logger.info(f"Received expression: {action}") + expression = extract_argument(action.arguments, "expression", "") + result = safe_evaluate(expression) + self.logger.info(f"Calculated result: {result}") + return result + + def _handle_finish(self, action: SingleAction) -> None: + self.logger.info(f"Received final answer: {action}") + answer = extract_argument(action.arguments, "answer", None) + self._final_answer = answer + self._done = True + return + + +# ── Evaluator ──────────────────────────────────────────────────────── + + +class GSM8kEvaluator(Evaluator): + """Evaluator for GSM8k — task discovery, session kwargs, aggregation.""" + + def __init__(self, subset: str = "main", include_calculator_tool: bool = True) -> None: + self._subset = subset + self._include_calculator_tool = include_calculator_tool + self._dataset = None + + def _ensure_dataset(self) -> None: + if self._dataset is None: + from datasets import load_dataset + + self._dataset = load_dataset("gsm8k", "main")["test"] + + def list_tasks(self) -> list[str]: + return [str(i) for i in range(GSM8K_TOTAL_TASKS)] + + def get_session_kwargs(self, index: SessionIndex) -> dict[str, Any]: + self._ensure_dataset() + idx = int(index.task_id) + if idx < 0 or idx >= len(self._dataset): + raise IndexError(f"Task id {index.task_id} out of range for GSM8k.") + instance = {"task_id": idx, **self._dataset[idx]} + return { + "settings": get_settings(), + "include_calculator_tool": self._include_calculator_tool, + "instance": instance, + "session_id": index.session_id, + } + + def aggregate_sessions(self, sessions: list[SessionIndex]) -> BenchmarkResults: + run_logger = _get_run_logger() + scores: list[float] = [] + for paths in self.get_sessions_paths(sessions): + fp = paths.benchmark_results + try: + with open(fp, encoding="utf-8-sig") as f: + payload = json.load(f) + s = float(payload["score"]) + scores.append(s) + except FileNotFoundError as err: + raise FileNotFoundError( + f"Missing benchmark result for session" f" '{paths.session_id}' at {fp}" + ) from err + except Exception: + run_logger.exception( + "Failed to load benchmark result for session %s at %s", + paths.session_id, + fp, + ) + raise + avg = sum(scores) / len(scores) if scores else 0.0 + return BenchmarkResults( + benchmark_name="gsm8k", + total_tasks=len(sessions), + score=avg, + metrics={}, + ) + + +# ── Benchmark config ───────────────────────────────────────────────── + + +class GSM8kBenchmark(Benchmark, BaseModel): + display_name: ClassVar[str] = "GSM8k" + slug_name: ClassVar[str] = "gsm8k" + model_config = ConfigDict(arbitrary_types_allowed=True) + + @classmethod + def _get_evaluator_class(cls): + return GSM8kEvaluator + + @classmethod + def _get_session_class(cls): + return GSM8kSession + + subset: Literal["main"] = "main" + include_calculator_tool: bool = True + runner: RunnerName | None = None # Threadsafe; uses global default runner + + def _get_evaluator_kwargs(self) -> dict[str, Any]: + return { + "subset": self.subset, + "include_calculator_tool": self.include_calculator_tool, + } diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/gsm8k/requirements.txt b/labs/AgentStream/exgentic/src/exgentic/benchmarks/gsm8k/requirements.txt new file mode 100644 index 00000000..aee11b28 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/gsm8k/requirements.txt @@ -0,0 +1 @@ +datasets diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/hle/__init__.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/hle/__init__.py new file mode 100644 index 00000000..367fec41 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/hle/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/hle/hle_benchmark.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/hle/hle_benchmark.py new file mode 100644 index 00000000..d128f6b3 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/hle/hle_benchmark.py @@ -0,0 +1,390 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import asyncio +import json +import math +from typing import Any, ClassVar, Literal + +import numpy as np +from pydantic import BaseModel, ConfigDict, Field + +from ...core.actions import ActionsHandler, extract_argument +from ...core.benchmark import Benchmark +from ...core.evaluator import Evaluator +from ...core.session import Session +from ...core.types import ( + Action, + ActionType, + BenchmarkResults, + EmptyObservation, + FinishAction, + Observation, + SessionIndex, + SessionScore, + SingleAction, + SingleObservation, +) +from ...utils.cost import CostReport, LiteLLMCostReport +from ...utils.settings import RunnerName + +HLE_TOTAL_TASKS = 2500 + +JUDGE_PROMPT = """Judge whether the following [response] to [question] is correct or not based on the precise and unambiguous [correct_answer] below. + +[question]: {question} + +[response]: {response} + +Your judgement must be in the format and criteria specified below: + +extracted_final_answer: The final exact answer extracted from the [response]. Put the extracted answer as 'None' if there is no exact, final answer to extract from the response. + +[correct_answer]: {correct_answer} + +reasoning: Explain why the extracted_final_answer is correct or incorrect based on [correct_answer], focusing only on if there are meaningful differences between [correct_answer] and the extracted_final_answer. Do not comment on any background to the problem, do not attempt to solve the problem, do not argue for any answer different than [correct_answer], focus only on whether the answers match. + +correct: Answer 'yes' if extracted_final_answer matches the [correct_answer] given above, or is within a small margin of error for numerical problems. Answer 'no' otherwise, i.e. if there if there is any inconsistency, ambiguity, non-equivalency, or if the extracted answer is incorrect. + + +confidence: The extracted confidence score between 0|%| and 100|%| from [response]. Put 100 if there is no confidence score available.""" + + +class ExtractedAnswer(BaseModel): + extracted_final_answer: str + reasoning: str + correct: Literal["yes", "no"] + confidence: int + strict: Literal[True] + + +class HLEFinishArgs(BaseModel): + explanation: str = Field(..., description="Your explanation/reasoning for your answer.") + answer: str = Field(..., description="Your final answer to the question.") + confidence: int = Field(..., description="Your confidence score between 0 and 100.", ge=0, le=100) + + +class HLEFinishAction(FinishAction): + name: Literal["finish"] = "finish" + arguments: HLEFinishArgs + +class HLESession(Session): + + def __init__( + self, + task_idx: int, + judge_model: str = "o3-mini-2025-01-31", + session_id: str | None = None, + agent_timeout: int = 900, + **_kwargs: Any, + ) -> None: + if session_id is not None: + self._session_id = session_id + from datasets import load_dataset + + dataset = load_dataset("cais/hle", split="test") + row = dataset[task_idx] + + self._question = row["question"] + self._gold_answer = row["answer"] + self._image = self._encode_image(row.get("image", None)) + self._task_id = task_idx + self._judge_model = judge_model + self._agent_timeout = agent_timeout + self._start_time: float | None = None + self._done = False + self._final_answer: str | None = None + self._final_explanation: str | None = None + self._final_confidence: int | None = None + self._judge_result: dict[str, Any] | None = None + self._judge_input_tokens = 0 + self._judge_output_tokens = 0 + + self._registry = ActionsHandler(logger=self.logger) + self._registry.add_action( + name="finish", + description="Submit your final answer with explanation and confidence score.", + action_cls=HLEFinishAction, + handler=self._handle_finish, + is_finish=True, + ) + super().__init__() + + @staticmethod + def _encode_image(image) -> str | None: + if image is None: + return None + if isinstance(image, str): + return image if image else None + + import base64 + import io + + buf = io.BytesIO() + image.save(buf, format="PNG") + b64 = base64.b64encode(buf.getvalue()).decode("utf-8") + return f"data:image/png;base64,{b64}" + + @property + def task(self) -> str: + return ( + "Answer the following question. Submit your answer by calling the 'finish' action " + "with your explanation, final answer, and confidence score (0-100).\n\n" + f"Question: {self._question}" + ) + + @property + def context(self) -> dict[str, Any]: + if self._image: + return {"image": {"type": "image_url", "data": self._image}} + return {} + + @property + def task_id(self) -> str: + return str(self._task_id) + + @property + def actions(self) -> list[ActionType]: + return self._registry.actions + + def start(self) -> Observation | None: + import time + self._start_time = time.time() + return EmptyObservation() + + def step(self, action: Action) -> Observation | None: + import time + if action is None: + self._done = True + if self._done: + return None + if self._start_time is not None: + elapsed = time.time() - self._start_time + if elapsed >= self._agent_timeout: + self._done = True + return SingleObservation( + result=f"[Agent timeout reached ({self._agent_timeout}s). Session ending.]" + ) + observation = self._registry.execute(action) + return observation + + def done(self) -> bool: + return self._done + + def _run_judge(self) -> dict[str, Any] | None: + if self._final_answer is None: + return None + + response_text = ( + f"Explanation: {self._final_explanation or ''}\n" + f"Answer: {self._final_answer}\n" + f"Confidence: {self._final_confidence or 100}%" + ) + + prompt = JUDGE_PROMPT.format( + question=self._question, + correct_answer=self._gold_answer, + response=response_text, + ) + + async def _judge_async() -> dict[str, Any] | None: + import litellm + + try: + resp = await litellm.acompletion( + model=self._judge_model, + max_tokens=4096, + messages=[{"role": "user", "content": prompt}], + response_format=ExtractedAnswer, + ) + import json as _json + + content = _json.loads(resp.choices[0].message.content) + usage = getattr(resp, "usage", None) + if usage is not None: + self._judge_input_tokens += int(getattr(usage, "prompt_tokens", 0) or 0) + self._judge_output_tokens += int(getattr(usage, "completion_tokens", 0) or 0) + return { + "correct_answer": self._gold_answer, + "model_answer": content["extracted_final_answer"], + "reasoning": content["reasoning"], + "correct": content["correct"], + "confidence": content["confidence"], + } + except Exception as e: + self.logger.warning(f"Judge failed: {e}") + return None + + return asyncio.run(_judge_async()) + + def score(self) -> SessionScore: + if self._judge_result is None: + self._judge_result = self._run_judge() + + if self._judge_result is not None: + correct = self._judge_result["correct"] == "yes" + score = 1.0 if correct else 0.0 + else: + score = 0.0 + + finished = self._final_answer is not None + return SessionScore( + score=score, + success=score == 1.0, + is_finished=finished, + session_metrics={ + "confidence": self._final_confidence, + "judge_result": self._judge_result, + }, + ) + + def close(self): + super().close() + sc = self.score() + self.save_standard_results(sc) + + def _handle_finish(self, action: SingleAction) -> None: + self._final_explanation = extract_argument(action.arguments, "explanation", None) + self._final_answer = extract_argument(action.arguments, "answer", None) + self._final_confidence = extract_argument(action.arguments, "confidence", 100) + self._done = True + return None + + def get_cost(self) -> CostReport: + if self._judge_input_tokens == 0 and self._judge_output_tokens == 0: + return LiteLLMCostReport.initialize_empty(model_name=self._judge_model) + return LiteLLMCostReport.from_token_counts( + self._judge_model, + self._judge_input_tokens, + self._judge_output_tokens, + ) + + + +def calib_err(confidence, correct, p="2", beta=100): + idxs = np.argsort(confidence) + confidence = confidence[idxs] + correct = correct[idxs] + bins = [[i * beta, (i + 1) * beta] for i in range(len(confidence) // beta)] + if not bins: + return 0.0 + bins[-1] = [bins[-1][0], len(confidence)] + + cerr = 0 + total_examples = len(confidence) + for i in range(len(bins) - 1): + bin_confidence = confidence[bins[i][0] : bins[i][1]] + bin_correct = correct[bins[i][0] : bins[i][1]] + num_examples_in_bin = len(bin_confidence) + + if num_examples_in_bin > 0: + difference = np.abs(np.nanmean(bin_confidence) - np.nanmean(bin_correct)) + if p == "2": + cerr += num_examples_in_bin / total_examples * np.square(difference) + elif p == "1": + cerr += num_examples_in_bin / total_examples * difference + elif p in ("infty", "infinity", "max"): + cerr = np.maximum(cerr, difference) + + if p == "2": + cerr = np.sqrt(cerr) + + return float(cerr) + + +class HLEEvaluator(Evaluator): + + def __init__(self, subset: str = "test", judge_model: str = "o3-mini-2025-01-31", agent_timeout: int = 900) -> None: + self._subset = subset + self._judge_model = judge_model + self._agent_timeout = agent_timeout + self._dataset = None + + def _ensure_dataset(self) -> None: + if self._dataset is None: + from datasets import load_dataset + + self._dataset = load_dataset("cais/hle", split="test") + + def list_tasks(self) -> list[str]: + self._ensure_dataset() + return [str(i) for i in range(len(self._dataset))] + + def get_session_kwargs(self, index: SessionIndex) -> dict[str, Any]: + self._ensure_dataset() + idx = int(index.task_id) + if idx < 0 or idx >= len(self._dataset): + raise IndexError(f"Task id {index.task_id} out of range for HLE.") + return { + "task_idx": idx, + "judge_model": self._judge_model, + "agent_timeout": self._agent_timeout, + "session_id": index.session_id, + } + + def aggregate_sessions(self, sessions: list[SessionIndex]) -> BenchmarkResults: + scores: list[float] = [] + confidences: list[float] = [] + corrects: list[float] = [] + + for paths in self.get_sessions_paths(sessions): + with open(paths.benchmark_results, encoding="utf-8-sig") as f: + payload = json.load(f) + s = float(payload["score"]) + scores.append(s) + corrects.append(s) + metrics = payload.get("session_metrics", {}) + conf = metrics.get("confidence") + if conf is not None: + confidences.append(float(conf) / 100.0) + else: + confidences.append(1.0) + + n = len(scores) + accuracy = 100 * sum(scores) / n if n else 0.0 + confidence_half_width = 1.96 * math.sqrt(accuracy * (100 - accuracy) / n) if n else 0.0 + + cal_err = 0.0 + if n > 0: + cal_err = 100 * calib_err( + np.array(confidences), np.array(corrects), p="2", beta=100 + ) + + return BenchmarkResults( + benchmark_name="hle", + total_tasks=n, + score=accuracy / 100.0, + metrics={ + "accuracy_pct": round(accuracy, 2), + "confidence_interval": round(confidence_half_width, 2), + "calibration_error": round(cal_err, 2), + }, + ) + + +class HLEBenchmark(Benchmark, BaseModel): + display_name: ClassVar[str] = "HLE" + slug_name: ClassVar[str] = "hle" + model_config = ConfigDict(arbitrary_types_allowed=True) + + @classmethod + def _get_evaluator_class(cls): + return HLEEvaluator + + @classmethod + def _get_session_class(cls): + return HLESession + + subset: Literal["test"] = "test" + judge_model: str = "o3-mini-2025-01-31" + agent_timeout: int = 900 + runner: RunnerName | None = None + + def _get_evaluator_kwargs(self) -> dict[str, Any]: + return { + "subset": self.subset, + "judge_model": self.judge_model, + "agent_timeout": self.agent_timeout, + } diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/hle/requirements.txt b/labs/AgentStream/exgentic/src/exgentic/benchmarks/hle/requirements.txt new file mode 100644 index 00000000..bf051a4f --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/hle/requirements.txt @@ -0,0 +1,3 @@ +datasets +numpy +litellm diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/hotpotqa/__init__.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/hotpotqa/__init__.py new file mode 100644 index 00000000..367fec41 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/hotpotqa/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/hotpotqa/hotpotqa_benchmark.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/hotpotqa/hotpotqa_benchmark.py new file mode 100644 index 00000000..8cea899f --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/hotpotqa/hotpotqa_benchmark.py @@ -0,0 +1,373 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import asyncio +import json +import re +import stat +import string +import textwrap +import threading +from collections import Counter +from typing import Any, ClassVar, Literal + +from pydantic import BaseModel, ConfigDict + +from ...adapters.schemas.openai import ( + mcp_tools_to_openai_tools, + openai_tools_to_action_types, +) + +# Copied scoring functions from https://github.com/hotpotqa/hotpot/blob/master/hotpot_evaluate_v1.py +from ...core.actions import ActionsHandler, extract_argument +from ...core.benchmark import Benchmark +from ...core.evaluator import Evaluator +from ...core.session import Session +from ...core.types import ( + Action, + ActionType, + BenchmarkResults, + EmptyObservation, + FinishAction, + Observation, + SessionIndex, + SessionScore, + SingleAction, + SingleObservation, +) +from ...utils.settings import RunnerName + +HOTPOTQA_TOTAL_TASKS = 7405 + + +def normalize_answer(s): + def remove_articles(text): + return re.sub(r"\b(a|an|the)\b", " ", text) + + def white_space_fix(text): + return " ".join(text.split()) + + def remove_punc(text): + exclude = set(string.punctuation) + return "".join(ch for ch in text if ch not in exclude) + + def lower(text): + return text.lower() + + return white_space_fix(remove_articles(remove_punc(lower(s)))) + + +def f1_score(prediction, ground_truth): + normalized_prediction = normalize_answer(prediction) + normalized_ground_truth = normalize_answer(ground_truth) + + zero_metric = (0, 0, 0) + + if normalized_prediction in ["yes", "no", "noanswer"] and normalized_prediction != normalized_ground_truth: + return zero_metric + if normalized_ground_truth in ["yes", "no", "noanswer"] and normalized_prediction != normalized_ground_truth: + return zero_metric + + prediction_tokens = normalized_prediction.split() + ground_truth_tokens = normalized_ground_truth.split() + common = Counter(prediction_tokens) & Counter(ground_truth_tokens) + num_same = sum(common.values()) + if num_same == 0: + return zero_metric + precision = 1.0 * num_same / len(prediction_tokens) + recall = 1.0 * num_same / len(ground_truth_tokens) + f1 = (2 * precision * recall) / (precision + recall) + return f1, precision, recall + + +class HotpotFinishArgs(BaseModel): + answer: str + + +class HotpotFinishAction(FinishAction): + name: Literal["finish"] = "finish" + arguments: HotpotFinishArgs + + +class HotpotQASession(Session): + """Session for HotpotQA benchmark evaluation.""" + + _question: str + _done: bool + + def __init__( + self, + with_search_tools: bool, + instance: dict[str, Any], + session_id: str | None = None, + **_kwargs: Any, + ) -> None: + if session_id is not None: + self._session_id = session_id + self._question = instance["question"] + self.logger.info(f"question: {self._question}") + self._gold_answer = instance["answer"] + self._task_id = instance["task_id"] + self._done = False + self._final_answer = None + self._with_search_tools = with_search_tools + self._registry = ActionsHandler(logger=self.logger) + self._mcp_ready = threading.Event() + self._mcp_error: BaseException | None = None + + self.mcp_thread: threading.Thread | None = None + if self._with_search_tools: + self.mcp_thread = threading.Thread(target=self.run_wikipedia_server, daemon=True) + self.mcp_thread.start() + ready = self._mcp_ready.wait(timeout=60.0) + if not ready or self._mcp_error is not None: + err = self._mcp_error + raise RuntimeError(f"MCP initialization failed or timed out: {err}") from err + else: + # No search tools requested; skip MCP startup. + self._mcp_ready.set() + # Only 'finish' is provided as the completion action + self._registry.add_action( + name="finish", + description="Submit the final answer and complete the task.", + action_cls=HotpotFinishAction, + handler=self._handle_finish, + is_finish=True, + ) + super().__init__() + + def run_wikipedia_server(self): + asyncio.run(self.run_wikipedia_server_async()) + + def _mcp_client_config(self) -> dict[str, Any]: + """Return a FastMCP client config that logs wikipedia-mcp stderr to the session benchmark dir.""" + log_path = self.paths.benchmark_dir / "wikipedia_mcp.log" + log_path.parent.mkdir(parents=True, exist_ok=True) + + from ...core.context import context_env + + ctx_env = context_env() + ctx_env_json = json.dumps(ctx_env) + + # Generate a tiny Python wrapper to keep stdout for JSONRPC and send stderr to a file. + wrapper_path = self.paths.benchmark_dir / "wikipedia_mcp_wrapper.py" + wrapper_code = ( + textwrap.dedent( + f""" + #!/usr/bin/env python3 + import subprocess, sys, os, json + + log = open({str(log_path)!r}, "ab", buffering=0) + os.environ.update(json.loads({ctx_env_json!r})) + proc = subprocess.Popen( + ["wikipedia-mcp", "--transport", "stdio"], + stderr=log, + ) + proc.wait() + sys.exit(proc.returncode) + """ + ).strip() + + "\n" + ) + wrapper_path.write_text(wrapper_code, encoding="utf-8") + wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + command = str(wrapper_path) + return {"mcpServers": {"wiki": {"command": command, "args": []}}} + + async def run_wikipedia_server_async(self): + if not self._with_search_tools: + self._mcp_ready.set() + return + try: + from fastmcp import Client + + config = self._mcp_client_config() + mcp_client = Client(config) + + async with mcp_client: + tools = await mcp_client.list_tools() + + openai_tools = mcp_tools_to_openai_tools(tools) + if self._with_search_tools: + self._registry.add_actions( + openai_tools_to_action_types(openai_tools), + self._handle_mcp_action, + ) + except Exception as e: + self._mcp_error = e + self.logger.exception(f"Failed to initialize wikipedia-mcp: {e}") + raise + finally: + self._mcp_ready.set() + + @property + def task(self) -> str: + return ( + "Answer the user question. Submit the final answer as a short phrase by calling 'finish'. " + "If the question is yes/no, answer 'yes' or 'no'. Do not add explanations.\n\n" + f"Question: {self._question}" + ) + + @property + def context(self) -> dict[str, Any]: + return {} + + @property + def task_id(self) -> str: + return str(self._task_id) + + @property + def actions(self) -> list[ActionType]: + return self._registry.actions + + def _to_observation(self, raw: Any, invoking: list[SingleAction] | None = None) -> Observation: + return SingleObservation(invoking_actions=invoking or [], result=raw) + + def start(self) -> Observation | None: + # Empty initial observation; question is carried in the task string. + return EmptyObservation() + + def run_mcp_command(self, name, arguments) -> Any: + return asyncio.run(self.run_mcp_command_async(name, arguments)) + + async def run_mcp_command_async(self, name, arguments) -> Any: + from fastmcp import Client + + config = self._mcp_client_config() + mcp_client = Client(config) + + async with mcp_client: + response = await mcp_client.call_tool(name=name, arguments=arguments.model_dump()) + # print(response.structured_content) + return response.structured_content + + def step(self, action: Action) -> Observation | None: + if action is None: + self._done = True + + if self._done: + return None + + observation = self._registry.execute(action) + + return observation + + def done(self) -> bool: + return self._done + + def score(self) -> SessionScore: + # Minimal: compute F1 and always mark success + try: + f1, precision, recall = f1_score(self._gold_answer, self._final_answer) + score = float(f1) + except Exception: + score = 0.0 + self.logger.info(f"Gold: {self._gold_answer} Prediction: {self._final_answer} Score: {score}") + # Finished only when the benchmark finish action stores a final answer. + finished = self._final_answer is not None + success = score >= 1.0 - 1e-6 + return SessionScore(score=score, success=success, is_finished=finished) + + def close(self): + super().close() + # Persist minimal results for aggregation + sc = self.score() + self.save_standard_results(sc) + self.logger.debug("Closing MCP server..") + if self.mcp_thread and self.mcp_thread.is_alive(): + self.logger.debug("Waiting for MCP server to shut down.") + self.mcp_thread.join(timeout=60.0) + if self.mcp_thread.is_alive(): + self.logger.warning("MCP server thread did shutdown cleanly, continuing anyway.") + else: + self.logger.debug("MCP server shutdown cleanly.") + + # Action handlers ------------------------------------------------------------ + def _handle_finish(self, action: SingleAction) -> Any: + self.logger.info(f"Received final answer: {action}") + answer = extract_argument(action.arguments, "answer", None) + self._final_answer = answer + self._done = True + return None + + def _handle_mcp_action(self, action: SingleAction) -> Any: + result = self.run_mcp_command(action.name, action.arguments) + return result + + +# ── Evaluator ──────────────────────────────────────────────────────── + + +class HotpotQAEvaluator(Evaluator): + """Evaluator for HotpotQA — task discovery, session kwargs, aggregation.""" + + def __init__(self, subset: str = "distractor", with_search_tools: bool = True) -> None: + self._subset = subset + self._with_search_tools = with_search_tools + self._dataset = None + + def _ensure_dataset(self) -> None: + if self._dataset is None: + from datasets import load_dataset + + self._dataset = load_dataset("hotpotqa/hotpot_qa", "distractor")["validation"] + + def list_tasks(self) -> list[str]: + return [str(i) for i in range(HOTPOTQA_TOTAL_TASKS)] + + def get_session_kwargs(self, index: SessionIndex) -> dict[str, Any]: + self._ensure_dataset() + idx = int(index.task_id) + if idx < 0 or idx >= len(self._dataset): + raise IndexError(f"Task id {index.task_id} out of range for HotpotQA.") + instance = {"task_id": idx, **self._dataset[idx]} + return { + "with_search_tools": self._with_search_tools, + "instance": instance, + "session_id": index.session_id, + } + + def aggregate_sessions(self, sessions: list[SessionIndex]) -> BenchmarkResults: + scores: list[float] = [] + for paths in self.get_sessions_paths(sessions): + with open(paths.benchmark_results, encoding="utf-8-sig") as f: + payload = json.load(f) + s = float(payload["score"]) + scores.append(s) + avg = sum(scores) / len(scores) if scores else 0.0 + return BenchmarkResults( + benchmark_name="hotpotqa", + total_tasks=len(sessions), + score=avg, + metrics={}, + ) + + +# ── Benchmark config ───────────────────────────────────────────────── + + +class HotpotQABenchmark(Benchmark, BaseModel): + display_name: ClassVar[str] = "HotpotQA" + slug_name: ClassVar[str] = "hotpotqa" + model_config = ConfigDict(arbitrary_types_allowed=True) + + @classmethod + def _get_evaluator_class(cls): + return HotpotQAEvaluator + + @classmethod + def _get_session_class(cls): + return HotpotQASession + + subset: Literal["distractor"] = "distractor" + with_search_tools: bool = True + runner: RunnerName | None = None # Threadsafe; uses global default runner (venv) + + def _get_evaluator_kwargs(self) -> dict[str, Any]: + return { + "subset": self.subset, + "with_search_tools": self.with_search_tools, + } diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/hotpotqa/requirements.txt b/labs/AgentStream/exgentic/src/exgentic/benchmarks/hotpotqa/requirements.txt new file mode 100644 index 00000000..5e9dae03 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/hotpotqa/requirements.txt @@ -0,0 +1,3 @@ +datasets +fastmcp +wikipedia-mcp diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/swebench/__init__.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/swebench/__init__.py new file mode 100644 index 00000000..367fec41 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/swebench/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/swebench/config.yaml b/labs/AgentStream/exgentic/src/exgentic/benchmarks/swebench/config.yaml new file mode 100644 index 00000000..aac16d0b --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/swebench/config.yaml @@ -0,0 +1,106 @@ +# SWE-bench Benchmark Configuration + +# Session settings +session: + timeout: 1000 # Default timeout for bash commands (seconds) + environment_pull_timeout: 600 # Timeout for Docker container startup/pull (seconds) + observation_size_limit: 10000 # Max characters in observation output + max_interactions: 200 # Max actions per session (null for unlimited) + timeout_template: | + The last command {command} timed out and has been killed. + The output of the command was: + {exception_output} + Please try another command + +# Task prompt template +# Available variables: {container_repo_dir}, {problem_statement} +task_prompt: | + Resolve the given issue by editing the repository files directly on a remote machine. + + Repository directory on the remote machine: {container_repo_dir} + + ## Issue to resolve: + {problem_statement} + + ## Execution Environment & Access (STRICT): + All commands are executed on a remote machine that already contains the full + repository and all required system dependencies and prerequisites. + + The remote machine is accessible **ONLY** via the `bash` action. + All interactions with the filesystem and environment — including reading files, + editing files, and running scripts — can be performed by definition only by using `bash`. + Each command is executed in a separate shell invocation; working directory changes + and environment variables do not persist between commands. Only files written to disk persist. + All bash commands are executed with the working directory set to the root of the remote machine (/). + + ## Instructions: + You must fix the issue by directly modifying files in this repository, in place. + + **ALL file edits MUST be performed using `bash` commands on the remote machine.** + Explanations or code snippets in chat are not sufficient. + + Only changes written to files on disk and present in the working tree will be + included in the final patch. Any fix not reflected in repository files is ignored. + + Make changes only to non-test source files in a way that is general and consistent + with the existing codebase. + + ## Hard Boundaries: + - MODIFY ONLY: regular source code files in /testbed + - DO NOT MODIFY: tests or configuration files (pyproject.toml, setup.cfg, etc.) + - DO NOT use interactive editors (vi, nano, etc.) + - DO NOT suggest changes without implementing them + + ## Patch & Submission Mechanics: + Submission captures the entire working tree using: + + `git add -A && git diff --staged C0` + + This means: + - All intended fixes MUST appear in repository files + - Any file present at submission time WILL be included in the patch + - Temporary files, debug artifacts, or helper scripts MUST be removed before submission + + ## Recommended Workflow: + 1. Analyze the codebase + 2. Reproduce the issue + 3. Edit source files using `bash` + 4. Verify the fix + 5. Test edge cases + + ## Submission (FINAL STEP): + When finished, use `submit_patch` exactly once with a short summary. + Ensure the repository contains ONLY the intended final changes. + After submission, no further reading, editing, or testing is allowed. + + ## Evaluation: + Your patch will be applied and validated using a hidden test suite. + + The evaluation verifies that: + 1. The reported issue is fully resolved + 2. All previously passing functionality and tests remain unbroken + + Success requires all tests to pass. + +# Benchmark settings +benchmark: + subset: "princeton-nlp/SWE-bench_Verified" + num_tasks: 3 # Number of tasks to run (null for all) + runner: "venv" # Runner type: "direct", "process", "venv", "docker" + seed: 42 + require_submit_for_patch_evaluation: true # If false, evaluate patch even without submit_patch + +# Evaluation settings +evaluation: + harness_timeout: 1800 # Timeout for SWE-bench harness evaluation (seconds) + max_workers: 1 # Number of parallel workers for evaluation + cache_level: "instance" # Cache level for evaluation + open_file_limit: 4096 # Open file limit for evaluation + +# Logging +logging: + hook_loggers: # Logger names to hook into session + - "minisweagent" + - "minisweagent.environment" + - "LiteLLM" + log_level: "DEBUG" diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/swebench/readme.md b/labs/AgentStream/exgentic/src/exgentic/benchmarks/swebench/readme.md new file mode 100644 index 00000000..2d70ea5f --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/swebench/readme.md @@ -0,0 +1,43 @@ +# SWE-bench + +Evaluate agents on real-world GitHub issues using the [SWE-bench](https://github.com/princeton-nlp/SWE-bench) benchmark. + +## Requirements + +- Linux x86_64 with Docker +- Python 3.11 in a virtual environment +- Run setup once: `bash src/exgentic/benchmarks/swebench/setup.sh` + +## Usage + +```python +from exgentic.core.orchestrator import run +from exgentic.benchmarks.swebench.swebench_benchmark import SWEBenchBenchmark +from exgentic.agents.litellm_tool_calling.litellm_tool_calling_agent import LiteLLMToolCallingAgent + +benchmark = SWEBenchBenchmark( + subset="princeton-nlp/SWE-bench_Lite", + num_tasks=10, +) +agent = LiteLLMToolCallingAgent(model="gpt-4o", max_steps=30) +results = run(benchmark, agent, output_dir="./outputs/swebench") +print("Avg score:", results.score) +``` + +## Output Structure + +Each session writes to `outputs//sessions//benchmark/`: + +``` +session.log - benchmark execution log +predictions.jsonl - generated patch +logs/ - SWE-bench harness evaluation logs +results.json - evaluation results (resolved, test pass rates) +``` + +## Notes + +- Each task runs in a dedicated Docker container via `minisweagent` +- Patches are evaluated using the official SWE-bench harness +- Invalid patches (missing diff markers) are rejected without evaluation +- Heavy projects (e.g., astropy) may need increased Docker RAM/CPU limits diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/swebench/requirements.txt b/labs/AgentStream/exgentic/src/exgentic/benchmarks/swebench/requirements.txt new file mode 100644 index 00000000..a3258815 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/swebench/requirements.txt @@ -0,0 +1,2 @@ +swebench @ git+https://github.com/SWE-bench/SWE-bench.git@v4.1.0 +mini-swe-agent @ git+https://github.com/SWE-agent/mini-swe-agent.git@v1.17.0 diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/swebench/swebench_benchmark.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/swebench/swebench_benchmark.py new file mode 100644 index 00000000..805f577c --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/swebench/swebench_benchmark.py @@ -0,0 +1,119 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""SWE-bench benchmark adapter -- light benchmark class only. + +Evaluator, session, and helper classes live in ``swebench_eval.py`` +and are loaded inside the runner subprocess via ``_get_evaluator_class()`` +and ``_get_session_class()``. This file must remain importable without +the ``swebench`` package installed. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, ClassVar + +from pydantic import BaseModel, ConfigDict, Field + +from ...core import Benchmark +from ...core.types import FinishAction, SingleAction +from ...core.types import SessionScore as BaseSessionScore + +# ============================================================================= +# Shared action / score types (importable without heavy deps) +# ============================================================================= + + +class BashArgs(BaseModel): + command: str = Field(description="Bash command to execute") + + +class BashAction(SingleAction): + name: str = "bash" + arguments: BashArgs + + +class SubmitPatchArgs(BaseModel): + summary: str = Field(description="Brief textual summary of the fix (no diff/patch)") + + +class SubmitPatchAction(FinishAction): + name: str = "finish" + arguments: SubmitPatchArgs + + +class SessionScore(BaseSessionScore): + instance_id: str = "" + agent: dict[str, Any] = Field(default_factory=dict) + patch: dict[str, Any] = Field(default_factory=dict) + container: dict[str, Any] = Field(default_factory=dict) + evaluation: dict[str, Any] = Field(default_factory=dict) + summary: dict[str, Any] = Field(default_factory=dict) + + +# ============================================================================= +# Configuration (light -- only stdlib + yaml lazy) +# ============================================================================= + +_CONFIG: dict[str, Any] = None + + +def get_config() -> dict[str, Any]: + import yaml + + global _CONFIG + if _CONFIG is None: + path = Path(__file__).parent / "config.yaml" + _CONFIG = yaml.safe_load(path.read_text()) + return _CONFIG + + +# ============================================================================= +# Benchmark +# ============================================================================= + + +class SWEBenchBenchmark(Benchmark): + """Benchmark configuration for SWE-bench evaluation.""" + + display_name: ClassVar[str] = "SWE-bench" + slug_name: ClassVar[str] = "swebench" + model_config = ConfigDict(arbitrary_types_allowed=True) + + @classmethod + def _get_evaluator_class(cls): + return "exgentic.benchmarks.swebench.swebench_eval:SWEBenchEvaluator" + + @classmethod + def _get_session_class(cls): + return "exgentic.benchmarks.swebench.swebench_eval:SWEBenchSession" + + subset: str | None = None + require_submit_for_patch_evaluation: bool = True + docker_socket: bool = True # SWE-bench sessions create sibling Docker containers + + def model_post_init(self, __context): + cfg = get_config() + benchmark_cfg = cfg["benchmark"] + session_cfg = cfg["session"] + if self.subset is None: + self.subset = benchmark_cfg["subset"] + if self.runner is None: + self.runner = benchmark_cfg.get("runner") + if "seed" in benchmark_cfg: + self.seed = benchmark_cfg["seed"] + if ( + "require_submit_for_patch_evaluation" in benchmark_cfg + and "require_submit_for_patch_evaluation" not in self.model_fields_set + ): + self.require_submit_for_patch_evaluation = bool(benchmark_cfg["require_submit_for_patch_evaluation"]) + if self.max_interactions is None: + self.max_interactions = session_cfg.get("max_interactions") + + def _get_evaluator_kwargs(self) -> dict[str, Any]: + return { + "subset": self.subset, + "require_submit_for_patch_evaluation": self.require_submit_for_patch_evaluation, + "max_interactions": self.max_interactions, + } diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/swebench/swebench_eval.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/swebench/swebench_eval.py new file mode 100644 index 00000000..c61b4cdb --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/swebench/swebench_eval.py @@ -0,0 +1,466 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""SWE-bench evaluator, session, and helper classes. + +These classes may import external dependencies (swebench, minisweagent, etc.) +at method level. They are only ever instantiated inside the isolated runner +subprocess, so the heavy dependencies are never required in the host process. + +The light ``SWEBenchBenchmark`` class lives in ``swebench_benchmark.py`` and +must remain importable without any external packages installed. +""" + +from __future__ import annotations + +import json +import logging +import subprocess +import textwrap +from pathlib import Path +from typing import Any + +from ...core.actions import ActionsHandler +from ...core.evaluator import Evaluator +from ...core.session import Session +from ...core.types import ( + Action, + ActionType, + BenchmarkResults, + Observation, + SessionIndex, + SingleObservation, +) +from ...utils.logging import hook_loggers_into_session +from ...utils.paths import get_run_id +from ...utils.settings import ExgenticSettings, get_settings +from . import swebench_evaluation, swebench_logs, swebench_metrics +from .swebench_benchmark import BashAction, SubmitPatchAction + +# ============================================================================= +# Configuration +# ============================================================================= + +_CONFIG: dict[str, Any] = None + + +def get_config() -> dict[str, Any]: + import yaml + + global _CONFIG + if _CONFIG is None: + path = Path(__file__).parent / "config.yaml" + _CONFIG = yaml.safe_load(path.read_text()) + return _CONFIG + + +# ============================================================================= +# Action Handlers +# ============================================================================= + + +def run_bash( + command: str, + env, + timeout: int | None = None, + size_limit: int | None = None, + timeout_template: str = "", +) -> dict[str, Any]: + """Execute bash command in environment.""" + try: + output = env.execute(command=command, timeout=timeout or env.config.timeout) + except subprocess.TimeoutExpired as e: + exception_output = e.output.decode("utf-8", errors="replace") if e.output else "" + msg = textwrap.dedent(timeout_template).format_map({"command": command, "exception_output": exception_output}) + output = {"output": msg, "returncode": 124} + + # Truncate large outputs + if size_limit and len(output["output"]) > size_limit: + head, tail = size_limit // 2, size_limit - size_limit // 2 + omitted = f"\n\n- OMITTED {len(output['output']) - size_limit} chars -\n\n" + output["output"] = output["output"][:head] + omitted + output["output"][-tail:] + + return output + + +def generate_patch(env, cwd: str, base_commit: str) -> str: + """Generate patch from staged changes.""" + command = f"git add -A && git diff --staged {base_commit} | cat" + output = env.execute(command=command, cwd=cwd) + return output["output"] + + +# ============================================================================= +# Session +# ============================================================================= + + +class SWEBenchSession(Session): + """Session for a single SWE-bench task.""" + + def __init__( + self, + settings: ExgenticSettings, + instance: dict[str, Any], + subset: str, + max_interactions: int | None = None, + require_submit_for_patch_evaluation: bool = True, + session_id: str | None = None, + ) -> None: + cfg = get_config() + + self._instance = instance + self._subset = subset + self._instance_id = instance["instance_id"] + if session_id is not None: + self._session_id = session_id + + self._registry = ActionsHandler( + logger=self.logger, + warn_on_validation_error=False, + warn_on_unknown_action=True, + handle_validation_error=None, + handle_unknown_action=None, + ) + + # State + self._step_count = 0 + self._done = False + self._max_interactions = max_interactions + self._require_submit_for_patch_evaluation = require_submit_for_patch_evaluation + self._action_count = 0 + self._score = None + self._final_patch: str | None = None + + # Environment (set in start()) + self.env = None + from swebench.harness.constants import DOCKER_WORKDIR + + self.container_repo_dir = DOCKER_WORKDIR + self.container_base_commit: str | None = None + + # Config + self._timeout = cfg["session"]["timeout"] + self._environment_pull_timeout = int(cfg["session"].get("environment_pull_timeout", 600)) + self._observation_size_limit = cfg["session"]["observation_size_limit"] + self._timeout_template = cfg["session"]["timeout_template"] + self._task_prompt = cfg["task_prompt"] + self._eval_config = cfg["evaluation"] + + self.logger.info( + f"INIT | Session initialized | dataset: {self._subset:<30} " + f"| instance_id: {self._instance_id:<20} | repo: {self._instance['repo']}" + ) + + hook_loggers_into_session( + self.logger, + logger_names=cfg["logging"].get("hook_loggers", []), + level=logging._nameToLevel.get(cfg["logging"]["log_level"], logging.INFO), + ) + + # Call parent to save session manifest (session.json) + super().__init__() + + # ------------------------------------------------------------------------- + # Lifecycle + # ------------------------------------------------------------------------- + + def start(self) -> Observation | None: + self.logger.info("START | Session start") + self._setup_environment() + return SingleObservation(invoking_actions=[], result=None) + + def done(self) -> bool: + return self._done + + def close(self): + self.logger.info("CLOSE | Session closing...") + super().close() + self.logger.debug("CLOSE | Cleaning agent environment") + del self.env + self.logger.info("CLOSE | Session closed") + + # ------------------------------------------------------------------------- + # Step Execution + # ------------------------------------------------------------------------- + + def _handle_bash(self, action: BashAction) -> str: + self.logger.info(f"STEP | {self._step_count:<3} | ACTION | bash | command: {action.arguments.command}") + result = run_bash( + command=action.arguments.command, + env=self.env, + timeout=self._timeout, + size_limit=self._observation_size_limit, + timeout_template=self._timeout_template, + ) + self.logger.info( + f"STEP | {self._step_count:<3} | RESULT | bash | " + f"returncode: {result['returncode']} | output_len: {len(result['output'])}" + ) + return result + + def _handle_submit_patch(self, action: SubmitPatchAction) -> str: + self.logger.info(f"STEP | {self._step_count:<3} | ACTION | submit_patch | summary: {action.arguments.summary}") + self._final_patch = generate_patch( + env=self.env, + cwd=self.container_repo_dir, + base_commit=self.container_base_commit, + ) + self._done = True + return None + + def step(self, action: Action) -> Observation | None: + if self._done: + return None + + if self._max_interactions is not None: + incoming = len(action.to_action_list()) + if self._action_count + incoming > self._max_interactions: + self.logger.warning( + "STEP | {self._step_count:<3} | Max interactions reached (%s/%s); terminating session", + self._action_count, + self._max_interactions, + ) + return None + + self._step_count += 1 + observation = self._registry.execute(action) + if observation is None: + return None + + # Post-execute: always track action count (useful for metrics) + self._action_count += len(action.to_action_list()) + + return observation + + # ------------------------------------------------------------------------- + # Scoring + # ------------------------------------------------------------------------- + + def score(self) -> swebench_logs.SessionScore: + """Compute and cache the session score.""" + if self._score is not None: + return self._score + + self.logger.info("SCORE | Computing session score") + + harness_result = None + if self._done or not self._require_submit_for_patch_evaluation: + if self._final_patch is None and not self._done: + self.logger.info("SCORE | submit_patch not called; generating patch from current workspace") + self._final_patch = self._generate_current_patch() + harness_result = self._run_harness() + else: + self.logger.info( + "SCORE | Skipping harness - submit_patch not called (require_submit_for_patch_evaluation=true)" + ) + + # Flush logs before parsing + for handler in self.logger.handlers: + handler.flush() + + # Build score + self._score = swebench_logs.build_score( + paths=self.paths, + num_actions=self._action_count, + max_interactions=self._max_interactions, + instance_id=self._instance_id, + harness_data=harness_result, + ) + + # Finished when agent called submit_patch (graceful completion) + self._score.is_finished = self._done + + results_path = self.paths.benchmark_results + swebench_logs.write_results(results_path, self._score) + self.logger.info( + "SCORE | Final | success=%s | score=%s | is_finished=%s", + self._score.success, + self._score.score, + self._score.is_finished, + ) + + return self._score + + def _run_harness(self) -> swebench_evaluation.HarnessResult | None: + """Run harness evaluation, handling errors.""" + if self._final_patch is None: + self.logger.info("SCORE | Harness evaluation skipped: no patch available for evaluation") + return None + try: + return swebench_evaluation.run_harness( + patch=self._final_patch, + instance_id=self._instance_id, + subset=self._subset, + paths=self.paths, + eval_config=self._eval_config, + logger=self.logger, + ) + except Exception as e: + self.logger.exception(f"SCORE | Harness evaluation failed: {e}") + return None + + def _generate_current_patch(self) -> str | None: + """Generate patch from current working tree for non-submit evaluation mode.""" + if self.env is None or self.container_base_commit is None: + self.logger.warning("SCORE | Cannot generate patch: environment/base commit unavailable") + return None + try: + return generate_patch( + env=self.env, + cwd=self.container_repo_dir, + base_commit=self.container_base_commit, + ) + except Exception as e: + self.logger.exception(f"SCORE | Patch generation failed: {e}") + return None + + # ------------------------------------------------------------------------- + # Properties + # ------------------------------------------------------------------------- + + @property + def actions(self) -> list[ActionType]: + if not self._registry.actions: + self._registry.add_action( + name="bash", + description="Run a bash command in the repo root and get the output", + action_cls=BashAction, + handler=self._handle_bash, + ) + self._registry.add_action( + name="finish", + description=( + "Finish the task by submitting a brief" + " summary. The system automatically computes" + " the git patch from the repository changes." + ), + action_cls=SubmitPatchAction, + handler=self._handle_submit_patch, + is_finish=True, + ) + return self._registry.actions + + @property + def task(self) -> str: + return self._task_prompt.format_map( + { + "container_repo_dir": self.container_repo_dir, + "problem_statement": self._instance["problem_statement"], + } + ) + + @property + def context(self) -> dict[str, str]: + return {} + + @property + def task_id(self) -> str: + return str(self._instance_id) + + # ------------------------------------------------------------------------- + # Environment Setup + # ------------------------------------------------------------------------- + + def _setup_environment(self) -> None: + """Initialize the Docker environment for the task.""" + self.logger.info("ENV | Setting up environment") + from ...utils.logging import capture_stdio_to_session + + with capture_stdio_to_session(self.logger): + import minisweagent + import yaml + from minisweagent.run.extra.swebench import get_sb_environment + + config_path = Path(minisweagent.__file__).parent / "config" / "extra" / "swebench.yaml" + config = yaml.safe_load(config_path.read_text()).copy() + config["environment"]["cwd"] = self.container_repo_dir + config["environment"]["pull_timeout"] = self._environment_pull_timeout + + self.env = get_sb_environment(config=config, instance=self._instance) + + result = self.env.execute(command="git rev-parse HEAD", cwd=self.container_repo_dir) + self.container_base_commit = result["output"].strip() + + if self.container_base_commit != self._instance["base_commit"]: + self.logger.error( + f"ENV | Base commit mismatch: expected {self._instance['base_commit']} " + f"| got {self.container_base_commit}" + ) + + +# ============================================================================= +# Evaluator +# ============================================================================= + + +class SWEBenchEvaluator(Evaluator): + """Evaluation logic for SWE-bench -- task discovery, session config, aggregation.""" + + def __init__( + self, + subset: str, + require_submit_for_patch_evaluation: bool = True, + max_interactions: int | None = None, + ) -> None: + self._subset = subset + self._require_submit_for_patch_evaluation = require_submit_for_patch_evaluation + self._max_interactions = max_interactions + self._dataset: Any = None + self._instances_by_id: dict[str, dict[str, Any]] = {} + + def list_tasks(self) -> list[str]: + self._ensure_dataset() + return list(self._instances_by_id.keys()) + + def get_session_kwargs(self, index: SessionIndex) -> dict[str, Any]: + self._ensure_dataset() + task_id_str = str(index.task_id) + instance = self._instances_by_id.get(task_id_str) + if instance is None: + raise KeyError(f"Unknown SWE-bench task id: {index.task_id}") + return { + "settings": get_settings(), + "instance": instance, + "subset": self._subset, + "max_interactions": self._max_interactions, + "require_submit_for_patch_evaluation": self._require_submit_for_patch_evaluation, + "session_id": index.session_id, + } + + def aggregate_sessions(self, sessions: list[SessionIndex]) -> BenchmarkResults: + scores: list[float] = [] + session_ids = [s.session_id for s in sessions] + for paths in self.get_sessions_paths(sessions): + if not paths.benchmark_results.exists(): + raise FileNotFoundError( + f"Missing results for planned session '{paths.session_id}' at {paths.benchmark_results}" + ) + with open(paths.benchmark_results, encoding="utf-8") as f: + payload = json.load(f) + scores.append(float(payload.get("score", 0.0))) + + metrics = swebench_metrics.collect_metrics(get_run_id(), session_ids) + + return BenchmarkResults( + benchmark_name="swebench", + total_tasks=len(sessions), + score=sum(scores) / len(scores) if scores else 0.0, + metrics=metrics["funnel"], + ) + + def _ensure_dataset(self) -> None: + if self._dataset is not None: + return + if self._subset is None: + raise ValueError("subset must be configured for SWE-bench.") + from datasets import load_dataset + + dataset = load_dataset(self._subset, split="test") + instances = list(dataset) + if not instances: + raise RuntimeError( + f"SWE-bench dataset '{self._subset}' returned 0 instances. Check dataset availability and HF auth." + ) + self._dataset = instances + self._instances_by_id = {str(inst["instance_id"]): inst for inst in instances} diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/swebench/swebench_evaluation.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/swebench/swebench_evaluation.py new file mode 100644 index 00000000..d2ea8977 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/swebench/swebench_evaluation.py @@ -0,0 +1,98 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import json +import os +from contextlib import contextmanager +from dataclasses import dataclass +from logging import Logger +from pathlib import Path +from typing import Any + +from ...utils.logging import capture_stdio_to_session +from ...utils.paths import SessionPaths + + +@dataclass +class HarnessResult: + harness_report: dict[str, Any] + patch: str + patch_valid: bool + harness_ran: bool + error: str | None = None + + +def is_patch_valid(patch: str) -> bool: + if not patch or not patch.strip(): + return False + return any(m in patch for m in ["---", "+++", "@@", "diff --git", "*** Begin Patch"]) + + +@contextmanager +def _pushd(path): + prev = os.getcwd() + os.chdir(path) + try: + yield + finally: + os.chdir(prev) + + +def run_harness( + patch: str, + instance_id: str, + subset: str, + paths: SessionPaths, + eval_config: dict[str, Any], + logger: Logger, +) -> HarnessResult: + patch = patch or "" + valid = is_patch_valid(patch) + + pred_path = paths.benchmark_dir / "predictions.jsonl" + pred_path.parent.mkdir(parents=True, exist_ok=True) + pred_path.write_text( + json.dumps( + { + "instance_id": instance_id, + "model_patch": patch, + "patch_valid": valid, + "model_name_or_path": "exgentic", + }, + ensure_ascii=False, + ) + + "\n" + ) + logger.info(f"EVAL | Writing patch | valid: {valid} | size: {len(patch)}") + + if not valid: + logger.warning("EVAL | Skipping harness - invalid patch structure") + return HarnessResult({}, patch, False, False) + + logger.info("EVAL | Running SWE-bench harness evaluation") + try: + from swebench.harness import run_evaluation + + with _pushd(paths.benchmark_dir), capture_stdio_to_session(logger): + report_path = run_evaluation.main( + dataset_name=subset, + split="test", + instance_ids=[instance_id], + predictions_path=pred_path.name, + max_workers=eval_config["max_workers"], + run_id="exgentic1", + namespace="swebench", + force_rebuild=False, + cache_level=eval_config["cache_level"], + clean=False, + open_file_limit=eval_config["open_file_limit"], + timeout=eval_config["harness_timeout"], + rewrite_reports=False, + modal=False, + ) + report = json.loads(Path(report_path).read_text()) if report_path and Path(report_path).is_file() else {} + logger.info("EVAL | Harness evaluation completed") + return HarnessResult(report, patch, True, True) + except Exception as e: + logger.exception(f"EVAL | Harness evaluation failed: {e}") + return HarnessResult({}, patch, True, True, str(e)) diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/swebench/swebench_logs.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/swebench/swebench_logs.py new file mode 100644 index 00000000..07a5c61d --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/swebench/swebench_logs.py @@ -0,0 +1,178 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import json +import re +from pathlib import Path +from typing import Any + +from ...utils.paths import SessionPaths +from .swebench_benchmark import SessionScore + +FILE_OPS_RE = re.compile(r"\b(cp|mv|rm|mkdir|touch|tee|patch)\b") +BUILD_PHASES = {"base": "build_base", "env": "build_env", "instances": "build_instance"} + + +def build_score( + paths: SessionPaths, + num_actions: int, + max_interactions: int, + instance_id: str, + harness_data: Any | None = None, +) -> SessionScore: + score = SessionScore(score=0.0, success=False, instance_id=instance_id) + score.agent = _parse_agent(paths) + score.patch = _parse_patch(paths) + score.container, score.evaluation = _parse_harness_files(paths, score.patch) + if harness_data: + _apply_harness_data(score, harness_data, instance_id) + score.summary = _build_summary(score, num_actions, max_interactions) + score.score = score.summary["score"] + return score + + +def _parse_agent(paths: SessionPaths) -> dict[str, Any]: + log_path = paths.benchmark_dir / "session.log" + if not log_path.exists(): + return {"commands": None, "edit_commands": None, "call_submit": False} + content = log_path.read_text() + commands = re.findall(r"\| command: (.+?)(?=\n(?:INFO|ERROR|\[LiteLM\])|$)", content, re.DOTALL) + edit_cmds = [c for c in commands if _is_edit_cmd(c)] + return { + "commands": commands or None, + "edit_commands": edit_cmds or None, + "call_submit": bool(re.search(r"submit_patch\s*\| summary:", content)), + } + + +def _parse_patch(paths: SessionPaths) -> dict[str, Any]: + pred_path = paths.benchmark_dir / "predictions.jsonl" + _none = {"generated": None, "length": None, "structurally_valid": None} + if not pred_path.exists(): + return _none + try: + lines = pred_path.read_text().splitlines() + if not lines: + return _none + pred = json.loads(lines[0]) + patch_len = len(pred.get("model_patch", "")) + return { + "generated": patch_len > 0, + "length": patch_len, + "structurally_valid": pred.get("patch_valid", False), + } + except (json.JSONDecodeError, IndexError): + return _none + + +def _parse_harness_files(paths: SessionPaths, patch: dict) -> tuple: + container = { + "required": None, + "build_base": None, + "build_env": None, + "build_instance": None, + "started": None, + "patch_exists": None, + "applying_patch": None, + "patch_applied": None, + "removed": None, + } + evaluation = {"grading": None, "resolved": None, "test_results": {}} + + container["required"] = bool(patch.get("generated") and patch.get("structurally_valid")) + if not container["required"]: + return container, evaluation + + for path in paths.benchmark_dir.rglob("*"): + if path.name == "build_image.log" and "build_images" in str(path): + try: + phase = path.parts[-3] + if phase in BUILD_PHASES: + container[BUILD_PHASES[phase]] = path.read_text().strip().endswith("Image built successfully!") + except IndexError: + pass + + elif path.name == "run_instance.log" and "run_evaluation" in str(path): + content = path.read_text() + if re.search(r"Container .* started", content): + container["started"] = True + if re.search(r"Intermediate patch for .* written to logs", content): + container["patch_exists"] = True + if re.search(r"now applying to container", content): + container["applying_patch"] = True + if re.search(r"Patch Apply Failed", content): + container["patch_applied"] = False + if re.search(r"Grading answer for .*", content): + evaluation["grading"] = True + if re.search(r"Container .* removed\.", content): + container["removed"] = True + if container.get("applying_patch") and container.get("patch_applied") is None: + container["patch_applied"] = True + + elif path.name == "report.json": + try: + report = json.loads(path.read_text()) + if len(report) == 1: + data = next(iter(report.values())) + container["patch_exists"] = data["patch_exists"] + container["patch_applied"] = data["patch_successfully_applied"] + evaluation["resolved"] = data["resolved"] + for case, cd in data.get("tests_status", {}).items(): + s, f = len(cd.get("success", [])), len(cd.get("failure", [])) + if s + f > 0: + rate = round(100 * s / (s + f), 2) + evaluation["test_results"][case] = { + "expected": s + f, + "success": s, + "failure": f, + "rate": rate, + "display": f"expected {s + f}: success {s}, failure {f}. success rate: {rate}%", + } + except json.JSONDecodeError: + pass + + return container, evaluation + + +def _apply_harness_data(score: SessionScore, hd: Any, instance_id: str): + if hd.harness_report: + submitted = set(hd.harness_report.get("submitted_ids", [])) + if submitted != {instance_id}: + raise ValueError(f"Instance ID mismatch in harness report: expected {{'{instance_id}'}}, got {submitted}") + if hd.harness_report.get("resolved_instances", 0) > 0: + score.evaluation["resolved"] = True + score.success = hd.error is None + + +def _build_summary(score: SessionScore, num_actions: int, max_interactions: int) -> dict[str, Any]: + c, e, a, p = score.container, score.evaluation, score.agent, score.patch + f2p = e.get("test_results", {}).get("FAIL_TO_PASS", {}) + p2p = e.get("test_results", {}).get("PASS_TO_PASS", {}) + final = int(e.get("resolved") or False) + return { + "num_actions": num_actions, + "actions_limit": max_interactions, + "num_edit_commands": len(a.get("edit_commands") or []), + "agent_call_submit": 1 if a.get("call_submit") else 0, + "patch_non_empty": 1 if p.get("generated") else 0, + "container_status": -1 if not c.get("required") else (1 if c.get("started") else 0), + "fail_to_pass_rate": f2p.get("rate") if isinstance(f2p, dict) else None, + "pass_to_pass_rate": p2p.get("rate") if isinstance(p2p, dict) else None, + "score": final, + } + + +def _is_edit_cmd(cmd: str) -> bool: + c = cmd.lower() + return ( + ("python" in c and any(p in c for p in ["write_text", "write_bytes", ".write(", "replace(", "open("])) + or "git apply" in c + or "sed -i" in c + or bool(FILE_OPS_RE.search(c)) + or ">>" in c + ) + + +def write_results(results_path: Path, score: SessionScore): + results_path.parent.mkdir(parents=True, exist_ok=True) + results_path.write_text(score.model_dump_json(indent=2)) diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/swebench/swebench_metrics.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/swebench/swebench_metrics.py new file mode 100644 index 00000000..3152efc8 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/swebench/swebench_metrics.py @@ -0,0 +1,31 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import json + +from ...utils.paths import RunPaths + +FUNNEL = [ + ("edit", lambda s: (s.get("num_edit_commands") or 0) > 0), + ("call_submit", lambda s: s.get("agent_call_submit") == 1), + ("non_empty_patch", lambda s: s.get("patch_non_empty") == 1), + ("container", lambda s: s.get("container_status") == 1), + ("fail_to_pass", lambda s: s.get("fail_to_pass_rate") == 100.0), + ("pass_to_pass", lambda s: s.get("pass_to_pass_rate") == 100.0), + ("score", lambda s: s.get("score") == 1), +] + + +def collect_metrics(run_id, session_ids: list[str]) -> dict: + from ...core.context import get_context + + run_paths = RunPaths(run_id=run_id, output_dir=get_context().output_dir) + summaries = [] + for sid in session_ids: + path = run_paths.session(sid).benchmark_results + if path.exists(): + summaries.append(json.loads(path.read_text()).get("summary", {})) + + return { + "funnel": {"total": len(summaries)} | {name: sum(1 for s in summaries if check(s)) for name, check in FUNNEL} + } diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/tau2/__init__.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/tau2/__init__.py new file mode 100644 index 00000000..4dacc76e --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/tau2/__init__.py @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import os +from pathlib import Path + + +def _resolve_tau2_data_dir() -> str: + """Return the tau2 data directory path. + + Checks the cache directory first (populated by ``setup.sh``), then falls + back to the legacy ``installation/`` path for backwards compatibility. + """ + from ...environment.instance import get_manager + + cache_data = get_manager().env_path("benchmarks/tau2") + if cache_data.is_dir(): + return str(cache_data) + + # Legacy path (pre-setup.sh installs that cloned into the package tree) + legacy = Path(__file__).resolve().parent / "installation" / "tau2-bench" / "data" + return str(legacy) + + +def get_tau2_data_dir() -> str: + """Return the tau2 data directory, resolving lazily on first call.""" + if "TAU2_DATA_DIR" not in os.environ: + os.environ["TAU2_DATA_DIR"] = _resolve_tau2_data_dir() + return os.environ["TAU2_DATA_DIR"] diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/tau2/requirements.txt b/labs/AgentStream/exgentic/src/exgentic/benchmarks/tau2/requirements.txt new file mode 100644 index 00000000..ec51ca5d --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/tau2/requirements.txt @@ -0,0 +1 @@ +tau2 @ git+https://github.com/sierra-research/tau2-bench.git@v0.1.3 diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/tau2/setup.sh b/labs/AgentStream/exgentic/src/exgentic/benchmarks/tau2/setup.sh new file mode 100644 index 00000000..e53e4cbf --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/tau2/setup.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. +set -euo pipefail + +TAU2_REPO="https://github.com/sierra-research/tau2-bench.git" +TAU2_REF="v0.1.3" +if [ -d "tau2/domains" ]; then + echo "tau2 data already present — skipping download." + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +echo "Cloning tau2-bench data files..." +git clone --depth 1 --branch "$TAU2_REF" --filter=blob:none --sparse "$TAU2_REPO" "$TMPDIR/tau2-bench" +cd "$TMPDIR/tau2-bench" +git sparse-checkout set data +cd - >/dev/null 2>&1 + +cp -r "$TMPDIR/tau2-bench/data/." "./" + +echo "tau2 data installed to env_dir" diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/tau2/system-deps.txt b/labs/AgentStream/exgentic/src/exgentic/benchmarks/tau2/system-deps.txt new file mode 100644 index 00000000..5664e303 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/tau2/system-deps.txt @@ -0,0 +1 @@ +git diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/tau2/tau2_benchmark.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/tau2/tau2_benchmark.py new file mode 100644 index 00000000..65415583 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/tau2/tau2_benchmark.py @@ -0,0 +1,61 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""TAU2 benchmark adapter — light benchmark class only. + +Evaluator, session, and proxy-agent classes live in ``tau2_eval.py`` +and are loaded inside the runner subprocess via ``_get_evaluator_class()`` +and ``_get_session_class()``. This file must remain importable without +the ``tau2`` package installed. +""" + +from __future__ import annotations + +from typing import Any, ClassVar, Literal + +from pydantic import BaseModel, ConfigDict + +from ...core import Benchmark + + +class TAU2Benchmark(Benchmark, BaseModel): + display_name: ClassVar[str] = "Tau Bench 2" + slug_name: ClassVar[str] = "tau2" + available_subsets: ClassVar[list[str]] = ["mock", "retail", "airline", "telecom"] + model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True) + + @classmethod + def _get_evaluator_class(cls): + return "exgentic.benchmarks.tau2.tau2_eval:TAU2Evaluator" + + @classmethod + def _get_session_class(cls): + return "exgentic.benchmarks.tau2.tau2_eval:TAU2Session" + + subset: Literal["mock", "retail", "airline", "telecom"] = "retail" + user_simulator_model: str = "openai/Azure/gpt-4.1" + llm_temperature_user: float = 0.0 + llm_user_input_cost_per_token: float | None = None + llm_user_output_cost_per_token: float | None = None + max_steps: int = 200 + max_errors: int = 10 + num_trials: int = 1 + score_path: str | None = None + + def list_subsets(self) -> list[str]: # type: ignore[override] + return list(self.available_subsets) + + def _get_evaluator_kwargs(self) -> dict[str, Any]: + return { + "subset": self.subset, + "user_simulator_model": self.user_simulator_model, + "llm_temperature_user": self.llm_temperature_user, + "llm_user_input_cost_per_token": self.llm_user_input_cost_per_token, + "llm_user_output_cost_per_token": self.llm_user_output_cost_per_token, + "max_steps": self.max_steps, + "max_errors": self.max_errors, + "num_trials": self.num_trials, + "seed": self.seed, + "score_path": self.score_path, + "use_cache": self.use_cache, + } diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/tau2/tau2_eval.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/tau2/tau2_eval.py new file mode 100644 index 00000000..75a65371 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/tau2/tau2_eval.py @@ -0,0 +1,679 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""TAU2 evaluator, session, and proxy-agent classes. + +These classes import tau2 (via tau2_shim) at module level. They are only +ever instantiated inside the isolated venv subprocess, so the heavy +``tau2`` dependency is never required in the host process. +""" + +from __future__ import annotations + +import builtins +import contextvars +import json +import logging +import os +import threading +import traceback +from pathlib import Path +from shutil import move +from typing import TYPE_CHECKING, Any + +from ...adapters.actions.chat import ChatActionContext +from ...adapters.executors.proxy import PairableProxyAgent, PairableProxySession +from ...adapters.schemas.openai import openai_tools_to_action_types +from ...core.actions import ActionsHandler +from ...core.evaluator import Evaluator +from ...core.types import ( + Action, + ActionType, + BenchmarkResults, + MessageAction, + SessionIndex, + SessionScore, + SingleAction, + SingleObservation, +) +from ...integrations.litellm.config import configure_litellm +from ...integrations.litellm.health import check_model_accessible_sync +from ...observers.logging import ( + add_loguru_file_sink, + attach_library_logger_to_handler, + close_logger, + get_logger, + remove_loguru_sink, + restore_library_logger, +) +from ...utils.cost import CostReport, LiteLLMCostReport, UpdatableCostReport +from ...utils.paths import get_run_id +from ...utils.settings import get_settings +from .tau2_shim import ( + AssistantMessage, + Console, + ConsoleDisplay, + LLMAgent, + MultiToolMessage, + Results, + RunConfig, + TerminationReason, + Tool, + ToolCall, + ToolMessage, + UserMessage, + compute_metrics, + is_successful, + load_tasks, + registry, + run_domain, +) + +if TYPE_CHECKING: + pass + +# Resolve settings once per module +settings = get_settings() +logger = get_logger(__name__) + + +def current_run_id() -> str: + return get_run_id() + + +PROXY_AGENT_NAME = "proxy_agent" +TAU2_TOTAL_TASKS = { + "mock": 9, + "retail": 114, + "airline": 50, + "telecom": 114, +} + + +def _echo_action(action: SingleAction) -> SingleAction: + """Return the action unchanged so registry can normalize/validate without altering behavior.""" + return action + + +def tau_message_to_user_tool_message(message: Any) -> dict[str, Any]: + """Convert Tau2 message objects into a generic chat-style payload.""" + if isinstance(message, UserMessage): + return {"role": "user", "content": message.content} + if isinstance(message, ToolMessage): + return { + "role": "tool", + "tool_call_id": str(message.id), + "content": message.content, + } + if isinstance(message, MultiToolMessage): + return [{"role": "tool", "tool_call_id": str(m.id), "content": m.content} for m in message.tool_messages] + return {"content": str(message)} + + +def assistant_message_to_tau_message(msg_dict: dict[str, Any]) -> AssistantMessage: + """Convert a generic assistant message dict into a Tau2 AssistantMessage.""" + tool_calls: list[ToolCall] | None = None + if "tool_calls" in msg_dict: + tool_calls = [ToolCall(**p) for p in msg_dict["tool_calls"]] + return AssistantMessage(role="assistant", content=msg_dict.get("content"), tool_calls=tool_calls) + + +class TAU2Session(PairableProxySession): + """Proxy session whose score reads a single-task results file (runs remote).""" + + def __init__( + self, + run_config: RunConfig, + output_dir: str, + use_cache: bool, + session_id: str | None = None, + ): + if session_id is not None: + self._session_id = session_id + # Prepare config first so base Session.__init__ can persist it. + if isinstance(run_config, dict): + run_config = RunConfig(**run_config) + self._cfg = run_config + self.use_cache = use_cache + self._cfg.save_to = self.session_id + + self.tools: list[Tool] = [] + self.domain_policy: str = "" + self._registry: ActionsHandler | None = None + self.file_path: str | None = None + self._runner_thread: threading.Thread | None = None + self._runner_error: Exception | None = None + self._chat_ctx = ChatActionContext() + self._user_input_tokens = 0 + self._user_output_tokens = 0 + self._user_total_cost = 0.0 + + self._registry = ActionsHandler( + logger=self.logger, + warn_on_validation_error=False, + warn_on_unknown_action=False, + handle_validation_error=lambda action, _msg: SingleObservation(invoking_actions=[action], result=action), + ) + self._registry.add_action( + name="message", + description="Send a message to the user.", + action_cls=MessageAction, + handler=_echo_action, + is_message=True, + ) + + # Load TAU2 environment tools to expose actions + environment_constructor = registry.get_env_constructor(self._cfg.domain) + environment = environment_constructor() + self.domain_policy = environment.get_policy() + tools = environment.get_tools() + openai_tools = [t.openai_schema for t in tools] + action_types = openai_tools_to_action_types(openai_tools) + self._registry.add_actions(action_types, _echo_action) + + # Persist config/manifest after actions/context are initialized + super().__init__() + self.logger.debug(f"Init session PID:{os.getpid()}") + + with open(self.paths.benchmark_task, "w", encoding="utf-8") as f: + payload = self.task # pydantic RunConfig + json.dump(payload, f, ensure_ascii=False, indent=2) + + with open(self.paths.benchmark_context, "w", encoding="utf-8") as f: + payload = self.context # pydantic RunConfig + json.dump(payload, f, ensure_ascii=False, indent=2) + + from . import get_tau2_data_dir + + base = Path(get_tau2_data_dir()) / "simulations" + base.mkdir(parents=True, exist_ok=True) + self.file_path = str((base / f"{self._cfg.save_to}.json").resolve()) + self.results_file = self.paths.benchmark_results + + # Check user simulator model accessibility before starting Tau2 runner + check_model_accessible_sync(self._cfg.llm_user, logger=self.logger) + + # Start Tau2 runner + self.logger.debug("Staging for pairing") + self.stage_for_pairing() + self.logger.debug("Staged OK") + + def _runner(): + self.logger.debug(f"Runner started PID:{os.getpid()}") + agent_name = self._cfg.agent + if agent_name not in registry.get_agents(): + registry.register_agent(TAU2ProxyAgent, agent_name) + # Prepare session log path and redirect Tau2 console + prints + log_fh = open(self.paths.benchmark_dir / "tau2_session.log", "a", encoding="utf-8") + prev_console = ConsoleDisplay.console + prev_print = builtins.print + prev_input = builtins.input + tau2_logger_state = None + loguru_sink_id = None + + for handler in self.logger.handlers: + if isinstance(handler, logging.FileHandler): + ConsoleDisplay.console = Console( + file=log_fh, + force_terminal=False, + color_system=None, + highlight=False, + ) + tau2_logger_state = attach_library_logger_to_handler("tau2", handler) + # Also route Loguru logs to the Tau2 session file if Loguru is used by Tau2 + loguru_sink_id = add_loguru_file_sink(log_fh, level="DEBUG", colorize=False) + break + + def _file_print(*args, **kwargs): + if "file" not in kwargs: + kwargs["file"] = log_fh + return prev_print(*args, **kwargs) + + builtins.print = _file_print + + # Prevent interactive prompts from causing EOFError in non-interactive runs + def _no_input(*args, **kwargs): + return "" + + builtins.input = _no_input + try: + if self.file_path and Path(self.file_path).exists(): + self.logger.info( + "Removing existing TAU2 simulation file before run: %s", + self.file_path, + ) + Path(self.file_path).unlink() + self.logger.info("Starting TAU2 run domain") + run_domain(self._cfg) + self.logger.info("TAU2 run completed") + except Exception as e: + self.logger.error(f"TAU2 run FAILED with Exception: {e}") + trace_str = traceback.format_exc() + self.logger.error(trace_str) + self._runner_error = e + finally: + builtins.print = prev_print + builtins.input = prev_input + ConsoleDisplay.console = prev_console + # Restore tau2 logger handlers + if tau2_logger_state is not None: + ( + tau2_logger, + prev_tau2_handlers, + prev_tau2_propagate, + ) = tau2_logger_state + restore_library_logger(tau2_logger, prev_tau2_handlers, prev_tau2_propagate) + remove_loguru_sink(loguru_sink_id) + log_fh.flush() + log_fh.close() + # Ensure any waiting session.step() unblocks when Tau2 run completes + self.logger.debug("Sending terminal observation to unblock session.step()") + self.put_observation(None) + # If session ended before pairing with agent, release the pairing semaphore + # to avoid deadlocks + self.unstage_for_pairing() + self.logger.debug("TAU2 runner thread finishing") + + # Copy the parent's contextvars so the daemon thread inherits + # the exgentic Context (run_id, session_id, output_dir, etc.). + ctx_copy = contextvars.copy_context() + t = threading.Thread(target=ctx_copy.run, args=(_runner,), daemon=True) + t.start() + self._runner_thread = t + + def get_config(self) -> dict[str, Any]: + return self._cfg.model_dump() + + # Proxy -> Exgentic observation mapping + def update_message(self, message: Any) -> None: + if isinstance(message, UserMessage): + usage = message.usage or {} + self._user_input_tokens += usage.get("prompt_tokens", 0) + self._user_output_tokens += usage.get("completion_tokens", 0) + if message.cost is not None: + self._user_total_cost += float(message.cost) + payload = tau_message_to_user_tool_message(message) + obs = self._chat_ctx.message_to_observation(payload) + self.put_observation(obs) + + @property + def task(self) -> str: + return ( + "You are a customer service agent that helps the" + " user according to the provided below." + " Try to be helpful and always follow the policy." + ) + + @property + def context(self) -> dict[str, Any]: + return {"policy": self.domain_policy} + + @property + def actions(self) -> list[ActionType]: + return self._registry.actions + + @property + def task_id(self) -> str: + if self._cfg.task_ids: + return str(self._cfg.task_ids[0]) + return "" + + def close(self): + self.logger.debug("Closing session") + try: + super().close() # This sets self.completed = True + t = self._runner_thread + self.logger.debug(f"Thread state: alive={t.is_alive() if t else 'None'}") + if t and t.is_alive(): + self.logger.debug("Waiting for runner thread") + t.join(timeout=10.0) + if t.is_alive(): + self.logger.warning("Runner thread did not exit cleanly, continuing anyway") + self.logger.debug("Thread join completed") + if not Path(self.results_file).exists(): + if self.file_path and Path(self.file_path).exists(): + self.logger.debug("Moving results file") + move(self.file_path, self.results_file) + self.logger.debug("File move completed") + else: + self.logger.error("Results file not found") + raise FileNotFoundError( + f"TAU2 results file not found for session {self.session_id}: {self.file_path}" + ) + + self.logger.debug("Closing logging") + if not t or not t.is_alive(): + close_logger(self.logger) + finally: + # Surface runner thread error after cleanup to avoid leaking resources + if self._runner_error is not None: + raise RuntimeError( + f"TAU2 runner thread failed with error: {self._runner_error}" + ) from self._runner_error + + def get_cost(self) -> CostReport: + def _custom_token_cost(input_tokens: int, output_tokens: int) -> float | None: + args = self._cfg.llm_args_user or {} + input_rate = args.get("input_cost_per_token") + output_rate = args.get("output_cost_per_token") + if input_rate is None and output_rate is None: + return None + input_cost = input_tokens * float(input_rate or 0.0) + output_cost = output_tokens * float(output_rate or 0.0) + return input_cost + output_cost + + def _report_from_usage(input_tokens: int, output_tokens: int) -> CostReport | None: + if input_tokens == 0 and output_tokens == 0: + return None + custom_total = _custom_token_cost(input_tokens, output_tokens) + if custom_total is not None: + report = UpdatableCostReport.initialize_empty(model_name=self._cfg.llm_user) + report.add_cost(custom_total) + return report + return LiteLLMCostReport.from_token_counts( + model_name=self._cfg.llm_user, + input_tokens=input_tokens, + output_tokens=output_tokens, + ) + + def _report_from_messages(messages: list[Any]) -> CostReport | None: + total_cost = 0.0 + has_cost = False + input_tokens = 0 + output_tokens = 0 + for message in messages: + if message.role != "user": + continue + usage = message.usage or {} + input_tokens += usage.get("prompt_tokens", 0) + output_tokens += usage.get("completion_tokens", 0) + if message.cost is not None: + total_cost += float(message.cost) + has_cost = True + if has_cost and total_cost > 0: + report = UpdatableCostReport.initialize_empty(model_name=self._cfg.llm_user) + report.add_cost(total_cost) + return report + return _report_from_usage(input_tokens, output_tokens) + + def _load_results(path: str | None) -> Results | None: + if not path: + return None + results_path = Path(path) + if not results_path.exists(): + return None + try: + return Results.load(path) + except Exception: + return None + + res = _load_results(self.results_file) + if res is None: + res = _load_results(self.file_path) + if res is not None and res.simulations: + sim = res.simulations[-1] + report = _report_from_messages(sim.messages) + if report is not None: + return report + + if self._user_total_cost > 0: + report = UpdatableCostReport.initialize_empty(model_name=self._cfg.llm_user) + report.add_cost(self._user_total_cost) + return report + + report = _report_from_usage(self._user_input_tokens, self._user_output_tokens) + if report is not None: + return report + + return LiteLLMCostReport.initialize_empty(model_name=self._cfg.llm_user) + + def score(self) -> SessionScore: + # Check if the runner thread encountered an error and surface it + if self._runner_error is not None: + raise RuntimeError(f"TAU2 runner thread failed with error: {self._runner_error}") from self._runner_error + # Ensure the results file is in place. score() may be called before + # close() by the framework, so move the tau2 simulation output now. + if not Path(self.results_file).exists(): + t = self._runner_thread + if t and t.is_alive(): + t.join(timeout=30.0) + if self.file_path and Path(self.file_path).exists(): + Path(self.results_file).parent.mkdir(parents=True, exist_ok=True) + move(self.file_path, self.results_file) + res = Results.load(self.results_file) + if not res.simulations: + self.logger.error("Tau2 produced no simulations; marking session as failed.") + # Finished is false when the underlying Tau2 run produced no simulations. + return SessionScore(score=0.0, success=False, is_finished=False) + + sim = res.simulations[-1] + + self.paths.benchmark_dir.mkdir(parents=True, exist_ok=True) + with open(self.paths.benchmark_dir / "dialog.log", "w", encoding="utf-8") as f: + prev_console = ConsoleDisplay.console + ConsoleDisplay.console = Console(file=f, force_terminal=False, color_system=None) + ConsoleDisplay.display_simulation(sim) + ConsoleDisplay.console = prev_console + + self.logger.info("Computing score") + self.logger.info(f"Score: {sim.reward_info.reward}") + # Finished only when Tau2 reports an agent/user stop termination. + termination = sim.termination_reason + # Default to not finished unless Tau2 says the run ended cleanly. + graceful = False + if isinstance(termination, TerminationReason): + graceful = termination in ( + TerminationReason.AGENT_STOP, + TerminationReason.USER_STOP, + ) + elif isinstance(termination, str): + graceful = termination in ("agent_stop", "user_stop") + session_metadata: dict[str, Any] = {} + session_metrics: dict[str, Any] = {} + if sim.reward_info is not None: + session_metadata["reward_info"] = sim.reward_info.model_dump(mode="json") + session_metrics["reward"] = sim.reward_info.reward + if sim.reward_info.db_check is not None: + session_metrics["db_check_db_match"] = sim.reward_info.db_check.db_match + session_metrics["db_check_db_reward"] = sim.reward_info.db_check.db_reward + return SessionScore( + score=sim.reward_info.reward, + success=is_successful(sim.reward_info.reward), + is_finished=graceful, + session_metrics=session_metrics, + session_metadata=session_metadata, + ) + + +class TAU2ProxyAgent(LLMAgent, PairableProxyAgent[TAU2Session]): + def __init__( + self, + tools: list[Tool], + domain_policy: str, + llm: str | None = None, + llm_args: dict | None = None, + ): + sess = self.adopt_staged_session() + sess.logger.debug(f"Agent adopted PID:{os.getpid()}") + sess.tools = tools + sess.domain_policy = domain_policy + self.session = sess + configure_litellm(config=settings.to_litellm_config(), cache_only=True) + super().__init__(tools, domain_policy, llm, llm_args) + + def generate_next_message(self, message: Any, state: TAU2Session | None): + self.session.logger.info(repr(message)) + return self.handle_observation(message, state) + + def get_init_state(self, message_history: list | None = None) -> TAU2Session: # type: ignore[override] + return self.session + + # BaseProxyAgent hooks + def create_session(self, first_observation: Any) -> TAU2Session: + return self.session + + def update_session_observation(self, session: TAU2Session, observation: Any) -> None: + session.update_message(observation) + + def action_to_response(self, action: Any | None, observation: Any, session: TAU2Session): + if action is None: + message = ( + AssistantMessage(role="assistant", content="__done__", tool_calls=None), + session, + ) + return message + + actions = self._expand_actions(action, session) + + msg_dict = session._chat_ctx.actions_to_assistant_message(actions) # type: ignore[attr-defined] + message = assistant_message_to_tau_message(msg_dict) + session.logger.info(repr(message)) + return (message, session) + + @classmethod + def is_stop(cls, message: AssistantMessage) -> bool: + """Check if the message is a stop message. + + By default the agent does not stop. + """ + return message.content == "__done__" + + # Registry helpers ------------------------------------------------------- + def _expand_actions(self, action: Action, session: TAU2Session) -> list[SingleAction]: + """Normalize raw Action into a list of SingleAction via registry.""" + registry = session._registry # type: ignore[attr-defined] + expanded: list[SingleAction] = [] + for raw in action.to_action_list(): + # Handlers are no-ops; this call just normalizes/validates the action shape. + obs = registry.normalize(raw) + if obs is None: + continue + for so in obs.to_observation_list(): + res = so.result + if isinstance(res, SingleAction): + expanded.append(res) + elif so.invoking_actions: + expanded.extend(so.invoking_actions) + elif isinstance(res, Action): + expanded.append(res) # type: ignore[arg-type] + return expanded + + +class TAU2Evaluator(Evaluator): + """Evaluation logic for TAU2 -- task discovery, session kwargs, aggregation.""" + + def __init__( + self, + subset: str, + user_simulator_model: str, + llm_temperature_user: float, + llm_user_input_cost_per_token: float | None, + llm_user_output_cost_per_token: float | None, + max_steps: int, + max_errors: int, + num_trials: int, + seed: int, + score_path: str | None, + use_cache: bool, + ): + self._subset = subset + self._user_simulator_model = user_simulator_model + self._llm_temperature_user = llm_temperature_user + self._llm_user_input_cost_per_token = llm_user_input_cost_per_token + self._llm_user_output_cost_per_token = llm_user_output_cost_per_token + self._max_steps = max_steps + self._max_errors = max_errors + self._num_trials = num_trials + self._seed = seed + self._score_path = score_path + self._use_cache = use_cache + + def list_tasks(self) -> list[str]: + tasks = load_tasks(task_set_name=self._subset) + return [str(t.id) for t in tasks] + + def get_session_kwargs(self, index: SessionIndex) -> dict[str, Any]: + task_id = index.task_id + + cfg = RunConfig( + domain=self._subset, + user="user_simulator", + task_set_name=None, + task_ids=[str(task_id)], + num_tasks=1, + agent=PROXY_AGENT_NAME, + llm_agent="unknown", + llm_args_agent={}, + llm_user=self._user_simulator_model, + llm_args_user={ + "temperature": self._llm_temperature_user, + "caching": settings.litellm_caching, + }, + num_trials=self._num_trials, + max_steps=self._max_steps, + max_errors=self._max_errors, + seed=self._seed, + log_level=settings.log_level, + max_concurrency=1, + is_remote=False, + save_to=None, # Will be overridden by TauSession. + ) + if self._llm_user_input_cost_per_token is not None: + cfg.llm_args_user["input_cost_per_token"] = self._llm_user_input_cost_per_token + if self._llm_user_output_cost_per_token is not None: + cfg.llm_args_user["output_cost_per_token"] = self._llm_user_output_cost_per_token + + return { + "run_config": cfg.model_dump(), + "output_dir": settings.output_dir, + "use_cache": self._use_cache, + "session_id": index.session_id, + } + + def aggregate_sessions(self, sessions: list[SessionIndex]) -> BenchmarkResults: + """Aggregate per-session Tau2 result files and expose a final score. + + - Computes Tau2 metrics via ``compute_metrics`` for detailed reporting. + - Derives a top-level ``score`` as the mean per-session reward to provide + a single scalar suitable for tracker summaries and comparisons. + """ + files: list[Path] = [] + for paths in self.get_sessions_paths(sessions): + fp = paths.benchmark_results + if not fp.exists(): + raise FileNotFoundError(f"Missing results for planned session '{paths.session_id}' at {fp}") + files.append(fp) + + base: Results | None = None + all_sims = [] + task_map: dict[str, Any] = {} + errored_tasks = 0 + for fp in files: + r = Results.load(fp) + if base is None: + base = r + assert len(r.simulations) <= 1 # At most one simulation per file. + assert len(r.tasks) == 1 + + if len(r.simulations) == 0: + errored_tasks += 1 + continue + + all_sims.extend(r.simulations) + for t in r.tasks: + task_map[t.id] = t + + total_sessions = len(sessions) + + # Minimal path: assume at least one simulation was produced for each planned session + assert len(all_sims) > 0 + assert base is not None + combined = Results(info=base.info, tasks=list(task_map.values()), simulations=all_sims) + m = compute_metrics(combined) + + return BenchmarkResults( + benchmark_name=f"tau2-{self._subset}", + total_tasks=total_sessions, + score=m.avg_reward, + metrics=m.as_dict(), + ) diff --git a/labs/AgentStream/exgentic/src/exgentic/benchmarks/tau2/tau2_shim.py b/labs/AgentStream/exgentic/src/exgentic/benchmarks/tau2/tau2_shim.py new file mode 100644 index 00000000..f1a5acc8 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/benchmarks/tau2/tau2_shim.py @@ -0,0 +1,82 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Tau2 import shim that centralizes logging configuration. + +This module suppresses Tau2's default console logging at import time and +provides re-exports for Tau2 symbols used by Exgentic. It also disables +propagation on the stdlib logger for Tau2 so library logs don't bubble to +the application's console. Session code can add file sinks as needed. +""" + +from __future__ import annotations + +import logging + +# 1) Quiet stdlib logging for the 'tau2' namespace +_tau2_logger = logging.getLogger("tau2") +if not _tau2_logger.handlers: + _tau2_logger.addHandler(logging.NullHandler()) +_tau2_logger.propagate = False + +# 2) Quiet Loguru's default console sink if Loguru is present +try: + from loguru import logger as _loguru + + # Remove all default sinks to avoid console output. Session code will add + # a file sink per session when needed. + try: + _loguru.remove() + except Exception: + pass +except Exception: + _loguru = None # type: ignore + +# 3) Resolve TAU2_DATA_DIR *before* importing tau2 so that +# tau2.utils.utils.DATA_DIR picks up the correct path at import time. +from . import get_tau2_data_dir # noqa: E402 + +get_tau2_data_dir() + +# 4) Re-export Tau2 modules used by Exgentic +from rich.console import Console # noqa: E402 +from tau2.agent.llm_agent import LLMAgent # noqa: E402 +from tau2.data_model.message import ( # noqa: E402 + AssistantMessage, + MultiToolMessage, + ToolCall, + ToolMessage, + UserMessage, +) +from tau2.data_model.simulation import Results, RunConfig, TerminationReason # noqa: E402 +from tau2.environment.tool import Tool # noqa: E402 +from tau2.metrics.agent_metrics import compute_metrics, is_successful # noqa: E402 +from tau2.registry import registry # noqa: E402 +from tau2.run import load_tasks, run_domain # noqa: E402 +from tau2.utils.display import ConsoleDisplay # noqa: E402 + +# Tau2's llm_utils disables LiteLLM cache by default; re-enable Exgentic cache here. +from ...integrations.litellm.config import configure_litellm # noqa: E402 +from ...utils.settings import get_settings # noqa: E402 + +configure_litellm(config=get_settings().to_litellm_config(), cache_only=True) + +__all__ = [ + "AssistantMessage", + "Console", + "ConsoleDisplay", + "LLMAgent", + "MultiToolMessage", + "Results", + "RunConfig", + "TerminationReason", + "Tool", + "ToolCall", + "ToolMessage", + "UserMessage", + "compute_metrics", + "is_successful", + "load_tasks", + "registry", + "run_domain", +] diff --git a/labs/AgentStream/exgentic/src/exgentic/core/__init__.py b/labs/AgentStream/exgentic/src/exgentic/core/__init__.py new file mode 100644 index 00000000..68f74cf9 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/core/__init__.py @@ -0,0 +1,45 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from .agent import Agent +from .agent_instance import AgentInstance +from .benchmark import Benchmark +from .evaluator import Evaluator +from .session import Session +from .types import ( + Action, + BenchmarkResults, + Integration, + Observation, + RunConfig, + RunPlan, + RunResults, + RunStatus, + SessionConfig, + SessionResults, + SessionScore, + SessionStatus, +) + +__all__ = [ + # Core interfaces + "Benchmark", + "Evaluator", + "Session", + "Agent", + "AgentInstance", + # Data models + "RunConfig", + "SessionResults", + "SessionConfig", + "RunPlan", + "SessionStatus", + "RunStatus", + "RunResults", + "Integration", + "Action", + "Observation", + # New typed models + "SessionScore", + "BenchmarkResults", +] diff --git a/labs/AgentStream/exgentic/src/exgentic/core/actions.py b/labs/AgentStream/exgentic/src/exgentic/core/actions.py new file mode 100644 index 00000000..0bc4ad56 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/core/actions.py @@ -0,0 +1,340 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import json +from collections import Counter +from logging import Logger +from typing import Any, Callable, Optional + +from pydantic import BaseModel, ValidationError + +from ..observers.logging import get_disabled_logger +from .types import ( + Action, + ActionType, + MultiObservation, + SingleAction, + SingleObservation, + ValidationReport, +) + +ActionHandler = Callable[[SingleAction], Optional[Any]] + + +def _parse_arguments_payload(arguments: Any) -> Any: + """Return arguments with best-effort JSON parsing for string inputs.""" + if isinstance(arguments, str): + try: + return json.loads(arguments) + except json.JSONDecodeError: + return arguments + return arguments + + +def _validation_report_error(report: ValidationReport) -> Optional[str]: + """Return a unified error string if the validation report marks the action invalid.""" + if not report.name_valid: + return report.error or "Invalid action name" + if not report.args_valid or not report.valid: + return report.error or "Invalid arguments" + return None + + +def build_action(action_type: ActionType, arguments: Any, *, action_id: Optional[str] = None) -> SingleAction: + """Best-effort construction of a SingleAction with validity flag and optional ID.""" + parsed_args = _parse_arguments_payload(arguments) + + data: dict[str, Any] = {"name": action_type.name, "arguments": parsed_args} + if action_id is not None: + data["id"] = action_id + + report = ValidationReport() + try: + action = action_type.cls.model_validate(data) + except ValidationError as exc: + report.valid = False + report.args_valid = False + report.error = format_validation_errors(exc) + report.details = {"errors": exc.errors()} + args_cls = action_type.arguments + if isinstance(parsed_args, dict) and isinstance(args_cls, type) and issubclass(args_cls, BaseModel): + try: + parsed_args = args_cls.model_validate(parsed_args) + data["arguments"] = parsed_args + except ValidationError: + try: + parsed_args = args_cls.model_construct(**parsed_args) + data["arguments"] = parsed_args + except Exception: + pass + action = action_type.cls.model_construct(**data) + + # Attach validation metadata on the action instance + try: + action.validation = report # type: ignore[attr-defined] + except Exception: + object.__setattr__(action, "validation", report) + return action + + +def build_unknown_action(name: str, arguments: Any = None, *, action_id: Optional[str] = None) -> SingleAction: + """Construct a best-effort unknown action marked invalid for name lookup paths.""" + parsed_args = _parse_arguments_payload(arguments) + + report = ValidationReport( + valid=False, + name_valid=False, + args_valid=True, + error="Unknown action", + details={"reason": "unknown_action"}, + ) + payload: dict[str, Any] = { + "name": name, + "arguments": parsed_args if parsed_args is not None else {}, + "validation": report, + } + if action_id is not None: + payload["id"] = action_id + # model_construct to avoid BaseModel validation on arbitrary arguments types + return SingleAction.model_construct(**payload) + + +def format_validation_errors(error: ValidationError) -> str: + """Format pydantic validation errors into a compact human-readable string.""" + result = "" + for err in error.errors(): + loc = ".".join(str(x) for x in err.get("loc", ()) if x is not Ellipsis) or "value" + msg = err.get("msg", "Invalid value") + input_value = err.get("input", None) + input_type = type(input_value).__name__ + + if err.get("type") == "missing": + result += f"The field '{loc}' is required but was not provided." + else: + result += f"Field '{loc}': {msg} " f"(received {input_value!r} of type {input_type})." + return result + + +def extract_argument(arguments: Any, field_name: str, default: Any = None) -> Any: + """Best-effort extraction for a field from BaseModel/dict-like payloads.""" + if isinstance(arguments, BaseModel): + return arguments.model_dump().get(field_name, default) + if isinstance(arguments, dict): + return arguments.get(field_name, default) + return default + + +class ActionsHandler: + """Central handler/registry for available actions and their handlers.""" + + def __init__( + self, + logger: Optional[Logger] = None, + *, + warn_on_validation_error: bool = True, + warn_on_unknown_action: bool = True, + handle_validation_error: Optional[Callable[[SingleAction, str], Optional[SingleObservation]]] = None, + handle_unknown_action: Optional[Callable[[SingleAction], Optional[SingleObservation]]] = None, + ): + if warn_on_unknown_action and handle_unknown_action is not None: + raise ValueError("Cannot both warn and custom-handle unknown actions; set warn_on_unknown_action=False") + if warn_on_validation_error and handle_validation_error is not None: + raise ValueError("Cannot both warn and custom-handle validation errors; set warn_on_validation_error=False") + self._logger = logger or get_disabled_logger() + self._actions: dict[str, ActionType] = {} + self._handlers: dict[str, ActionHandler] = {} + self._warn_on_validation_error = warn_on_validation_error + self._warn_on_unknown_action = warn_on_unknown_action + self._handle_validation_error = handle_validation_error + self._handle_unknown_action = handle_unknown_action or self._default_unknown_action + self._stats: Counter[str] = Counter() + + # Registration ---------------------------------------------------------------- + def add_action( + self, + name: str, + description: str, + action_cls: type[SingleAction], + handler: ActionHandler, + *, + is_finish: bool = False, + is_message: bool = False, + is_hidden: bool = False, + ) -> ActionType: + """Register a new action by specifying its parts; the ActionType is constructed internally.""" + action = ActionType( + name=name, + description=description, + cls=action_cls, + is_finish=is_finish, + is_message=is_message, + is_hidden=is_hidden, + ) + self.add_action_type(action, handler) + return action + + def add_action_type(self, action: ActionType, handler: ActionHandler) -> None: + """Register an already-constructed ActionType.""" + if not isinstance(action, ActionType): + raise ValueError("action must be an ActionType") + self._store_action(action, handler) + + def add_actions(self, actions: list[ActionType], handler: ActionHandler) -> None: + for action in actions: + self.add_action_type(action, handler) + + # Accessors ------------------------------------------------------------------- + @property + def actions(self) -> list[ActionType]: + all_actions = list(self._actions.values()) + return list(filter(lambda action: not action.is_hidden, all_actions)) + + def normalize( + self, + action: Optional[Action], + ) -> Optional[SingleObservation | MultiObservation]: + """Alias for execute() to emphasize validation/normalization use-cases.""" + return self.execute(action) + + # Execution ------------------------------------------------------------------- + def execute( + self, + action: Optional[Action], + ) -> Optional[SingleObservation | MultiObservation]: + """Execute user-supplied action(s) through registered handlers and return a merged observation.""" + if action is None: + return None + + observations: list[SingleObservation] = [] + + for single_action in action.to_action_list(): + outcome = self._execute_single(single_action) + if outcome is not None: + observations.append(outcome) + + if not observations: + return None + if len(observations) == 1: + return observations[0] + return MultiObservation(observations=observations) + + def _execute_single(self, action: SingleAction) -> Optional[SingleObservation]: + handler = self._handlers.get(action.name) + + if handler is None: + self._logger.error(f"Unknown action requested: {action.name}") + self._record_error("unknown_action") + return self._normalize_handler_result(self._handle_unknown_action(action), action) + + validation_error = self._validate_arguments(action) + if validation_error: + message = f"Validation Error in {action.name}: {validation_error}" + self._logger.error(message) + self._record_error("validation_error") + if self._handle_validation_error is not None: + return self._normalize_handler_result( + self._handle_validation_error(action, validation_error), + action, + ) + if not self._warn_on_validation_error: + return None + return self._normalize_handler_result(message, action) + + try: + raw_result = handler(action) + except Exception as exc: # pragma: no cover - defensive + self._logger.exception(f"Action handler failed for {action.name}: {exc}") + self._record_error("handler_exception") + return self._normalize_handler_result( + f"Action '{action.name}' failed: {exc}", + action, + ) + + observation = self._normalize_handler_result(raw_result, action) + return observation + + def _validate_arguments(self, action: SingleAction) -> Optional[str]: + arguments = action.arguments + expected_type = self._expected_arguments_type(action) + + report: Optional[ValidationReport] = action.validation + if report: + error = _validation_report_error(report) + if error: + return error + return None + + # If arguments already a BaseModel, validate round-trip + if isinstance(arguments, BaseModel): + try: + arguments.__class__.model_validate(arguments.model_dump()) + except ValidationError as e: + return format_validation_errors(e) + return None + + # If we know the expected type and got a dict, try to validate/construct + if expected_type and issubclass(expected_type, BaseModel) and isinstance(arguments, dict): + try: + expected_type.model_validate(arguments) + except ValidationError as e: + return format_validation_errors(e) + return None + + # Unknown/invalid argument shape + if not isinstance(arguments, (BaseModel, dict)): + msg = f"Invalid arguments type: {type(arguments).__name__}" + if self._logger: + self._logger.error(msg) + self._record_error("invalid_arguments_type") + return msg + return None + + @staticmethod + def _expected_arguments_type(action: SingleAction) -> Optional[type]: + try: + field = action.__class__.model_fields.get("arguments") + if field and isinstance(field.annotation, type): + return field.annotation # type: ignore[return-value] + except Exception: + return None + return None + + def _record_error(self, key: str) -> None: + self._stats[key] += 1 + + def get_errors_stats(self) -> dict[str, int]: + return dict(self._stats) + + def _default_unknown_action(self, action: SingleAction) -> SingleObservation: + if action.name == "message": + text = "Error: Sending a message is not allowed. Please use only one of the available actions." + else: + text = f"Error: Unknown action - {action.name}" + return SingleObservation(invoking_actions=[action], result=text) + + @staticmethod + def _normalize_handler_result(raw_result: Any, action: SingleAction) -> Optional[SingleObservation]: + if raw_result is None: + return None + if isinstance(raw_result, SingleObservation): + if not raw_result.invoking_actions: + raw_result.invoking_actions = [action] + return raw_result + return SingleObservation(invoking_actions=[action], result=raw_result) + + def _store_action(self, action: ActionType, handler: ActionHandler) -> None: + self._validate_action_type(action) + self._actions[action.name] = action + self._handlers[action.name] = handler + + @staticmethod + def _validate_action_type(action: ActionType) -> None: + args_type = action.arguments + if not isinstance(args_type, type) or not issubclass(args_type, BaseModel): + raise ValueError( + "Action arguments must be a Pydantic BaseModel. " + f"Action '{action.name}' has arguments type {args_type!r}. " + "Ensure the action's 'arguments' annotation resolves to a BaseModel." + ) diff --git a/labs/AgentStream/exgentic/src/exgentic/core/agent.py b/labs/AgentStream/exgentic/src/exgentic/core/agent.py new file mode 100644 index 00000000..ea6f0c1a --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/core/agent.py @@ -0,0 +1,89 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, ClassVar + +from pydantic import BaseModel, ConfigDict + +from ..utils.settings import RunnerName +from .runner_mixin import RunnerMixin +from .types.model_settings import ModelSettings + +if TYPE_CHECKING: + from .agent_instance import AgentInstance + + +class Agent(BaseModel, RunnerMixin, ABC): + """Agent configuration — lightweight config that lives on the host. + + Callers use ``get_instance(session_id)`` to obtain a running + ``AgentInstance`` wrapped in the configured runner, mirroring + ``Benchmark.get_evaluator()`` and ``Benchmark.get_session()``. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True) + + display_name: ClassVar[str] + slug_name: ClassVar[str] + model_settings: ModelSettings | None = None + runner: RunnerName | None = None + docker_socket: bool = False + + @classmethod + @abstractmethod + def _get_instance_class(cls) -> type[AgentInstance]: + """Return the AgentInstance subclass for this agent. + + Subclasses implement this with a lazy import so heavy deps + (litellm, smolagents, …) are only loaded inside the runner. + """ + ... + + @classmethod + def _get_instance_class_ref(cls) -> str: + """Return a ``"module:qualname"`` string for the instance class. + + By default calls ``_get_instance_class()`` and converts to string. + Override in subclasses whose instance module has heavy third-party + imports to return the string directly without triggering the import. + """ + klass = cls._get_instance_class() + return f"{klass.__module__}:{klass.__qualname__}" + + @abstractmethod + def _get_instance_kwargs( + self, + session_id: str, + ) -> dict[str, Any]: + """Return kwargs for creating the instance class. + + Task, context, and actions are passed separately via + ``AgentInstance.start()`` (through HTTP transport) to avoid + OS argument-list size limits. + """ + ... + + def get_instance(self, session_id: str) -> AgentInstance: + """Create an ``AgentInstance`` wrapped in the configured runner.""" + from ..adapters.runners import with_runner + + return with_runner( + self._get_instance_class_ref(), + runner=self.resolve_runner(), + **self._get_instance_kwargs(session_id=session_id), + **self.runner_kwargs(), + ) + + # Optional metadata property for dashboard/leaderboards + @property + def model_name(self) -> str: + return "unknown" + + def get_models_names(self) -> list[str]: + name = self.model_name + if not name or name == "unknown": + return [] + return [name] diff --git a/labs/AgentStream/exgentic/src/exgentic/core/agent_instance.py b/labs/AgentStream/exgentic/src/exgentic/core/agent_instance.py new file mode 100644 index 00000000..4bba6868 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/core/agent_instance.py @@ -0,0 +1,84 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import uuid +from abc import ABC, abstractmethod +from typing import Any, Optional + +from ..observers.logging import get_logger +from ..utils.cost import CostReport +from ..utils.paths import SessionPaths +from .types import Action, ActionType, Observation + + +class AgentInstance(ABC): + """Agent instance - handles decision making for one task execution.""" + + max_steps: int | None = None + + def __init__(self, session_id: str) -> None: + """Create a new agent bound to a specific session. + + The session id is the single source of truth for scoping all agent-side + logs and artifacts under `outputs//sessions//agent/`. + """ + self._session_id = session_id + + @property + def session_id(self) -> str: + return self._session_id + + @property + def agent_id(self) -> str: + """Generates a unique id for the agent.""" + if not hasattr(self, "_agent_id"): + self._agent_id = str(uuid.uuid4()).replace("-", "_") + return self._agent_id + + @property + def paths(self) -> SessionPaths: + """All filesystem paths for this session.""" + if not hasattr(self, "_paths"): + from .context import try_get_context + + ctx = try_get_context() + if ctx is not None: + self._paths = SessionPaths( + session_id=self.session_id, + run_id=ctx.run_id, + output_dir=ctx.output_dir, + ) + else: + self._paths = SessionPaths(session_id=self.session_id, run_id="default", output_dir="outputs") + return self._paths + + @property + def logger(self): + if not hasattr(self, "_logger"): + self._logger = get_logger(f"Agent_{self.agent_id}", str(self.paths.agent_log)) + return self._logger + + def get_cost(self) -> CostReport: + """Estimated monetary cost; default 0.0.""" + return CostReport.initialize_empty() + + @abstractmethod + def react(self, observation: Optional[Observation]) -> Optional[Action]: + """React to observation - agent controls decision making, None = done.""" + pass + + def start(self, task: str, context: dict[str, Any], actions: list[ActionType]): + """Receive the work payload and start the agent. + + Called via HTTP transport after the instance is constructed, so + large payloads (e.g. dozens of ActionTypes) are never serialized + as CLI arguments. + """ + self.task = task + self.context = context or {} + self.actions = actions + + @abstractmethod + def close(self) -> None: + """Cleanup agent resources - agent manages its own state.""" + pass diff --git a/labs/AgentStream/exgentic/src/exgentic/core/benchmark.py b/labs/AgentStream/exgentic/src/exgentic/core/benchmark.py new file mode 100644 index 00000000..24390120 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/core/benchmark.py @@ -0,0 +1,94 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from abc import ABC +from typing import TYPE_CHECKING, Any + +from pydantic import BaseModel, ConfigDict + +from ..utils.settings import RunnerName +from .runner_mixin import RunnerMixin + +if TYPE_CHECKING: + from .evaluator import Evaluator + from .session import Session + + +class Benchmark(BaseModel, RunnerMixin, ABC): + """Benchmark configuration — lightweight config that lives on the host. + + Callers use ``get_evaluator()`` and ``get_session()`` to obtain + instances wrapped in the configured runner for container isolation. + """ + + model_config = ConfigDict( + arbitrary_types_allowed=True, + validate_by_name=True, + validate_by_alias=True, + ) + + subset: str | None = None + seed: int = 300 + runner: RunnerName | None = None + use_cache: bool = True + max_interactions: int | None = 150 + docker_socket: bool = False + + @property + def subset_name(self) -> str: + """Stable subset identifier for this benchmark run.""" + return str(self.subset) if self.subset else "unknown" + + def list_subsets(self) -> list[str]: + """Return available subset identifiers for this benchmark.""" + subset = self.subset_name + return [subset] if subset and subset != "unknown" else [] + + @classmethod + def _get_evaluator_class(cls) -> type[Evaluator]: + """Return the Evaluator subclass for this benchmark. + + Subclasses implement this with a lazy import so heavy deps + are only loaded inside the runner. + """ + raise NotImplementedError + + @classmethod + def _get_session_class(cls) -> type[Session]: + """Return the Session subclass for this benchmark. + + Subclasses implement this with a lazy import so heavy deps + are only loaded inside the runner. + """ + raise NotImplementedError + + def _get_evaluator_kwargs(self) -> dict[str, Any]: + """Return kwargs for constructing the Evaluator. + + Subclasses override this to pass benchmark-specific config. + """ + return {} + + def get_evaluator(self) -> Evaluator: + """Create an ``Evaluator`` wrapped in the configured runner.""" + from ..adapters.runners import with_runner + + return with_runner( + self._get_evaluator_class(), + runner=self.resolve_runner(), + **self._get_evaluator_kwargs(), + **self.runner_kwargs(), + ) + + def get_session(self, **session_kwargs: Any) -> Session: + """Create a ``Session`` wrapped in the configured runner.""" + from ..adapters.runners import with_runner + + return with_runner( + self._get_session_class(), + runner=self.resolve_runner(), + **session_kwargs, + **self.runner_kwargs(), + ) diff --git a/labs/AgentStream/exgentic/src/exgentic/core/context.py b/labs/AgentStream/exgentic/src/exgentic/core/context.py new file mode 100644 index 00000000..e4fd6d63 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/core/context.py @@ -0,0 +1,335 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import contextvars +import os +import shutil +import threading +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import Iterator + +from ..utils.paths import sanitize_path_component +from ..utils.settings import get_settings + +# --------------------------------------------------------------------------- +# Role enum +# --------------------------------------------------------------------------- + + +class Role(str, Enum): + FRAMEWORK = "framework" + AGENT = "agent" + BENCHMARK = "benchmark" + + +# --------------------------------------------------------------------------- +# OTEL Context dataclass +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class OtelContext: + """OpenTelemetry span context for distributed tracing.""" + + trace_id: str + span_id: str + + +# --------------------------------------------------------------------------- +# Env-var keys used for subprocess transport +# --------------------------------------------------------------------------- + +_ENV_RUN_ID = "EXGENTIC_CTX_RUN_ID" +_ENV_OUTPUT_DIR = "EXGENTIC_CTX_OUTPUT_DIR" +_ENV_CACHE_DIR = "EXGENTIC_CTX_CACHE_DIR" +_ENV_SESSION_ID = "EXGENTIC_CTX_SESSION_ID" +_ENV_TASK_ID = "EXGENTIC_CTX_TASK_ID" +_ENV_ROLE = "EXGENTIC_CTX_ROLE" +ENV_OTEL_TRACE_ID = "EXGENTIC_CTX_OTEL_TRACE_ID" +ENV_OTEL_SPAN_ID = "EXGENTIC_CTX_OTEL_SPAN_ID" +OTEL_ENABLED_ENV = "EXGENTIC_OTEL_ENABLED" + + +# --------------------------------------------------------------------------- +# Core Context dataclass +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Context: + run_id: str + output_dir: str + cache_dir: str + session_id: str | None = None + task_id: str | None = None + role: Role = Role.FRAMEWORK + otel_context: OtelContext | None = None + + def with_session(self, session_id: str, task_id: str | None = None) -> Context: + return Context( + run_id=self.run_id, + output_dir=self.output_dir, + cache_dir=self.cache_dir, + session_id=session_id, + task_id=task_id, + role=self.role, + otel_context=self.otel_context, + ) + + def with_role(self, role: Role) -> Context: + return Context( + run_id=self.run_id, + output_dir=self.output_dir, + cache_dir=self.cache_dir, + session_id=self.session_id, + task_id=self.task_id, + role=role, + otel_context=self.otel_context, + ) + + def with_otel_context(self, otel_context: OtelContext | None) -> Context: + """Create a new Context with updated OTEL context.""" + return Context( + run_id=self.run_id, + output_dir=self.output_dir, + cache_dir=self.cache_dir, + session_id=self.session_id, + task_id=self.task_id, + role=self.role, + otel_context=otel_context, + ) + + def to_env(self) -> dict[str, str]: + env: dict[str, str] = { + _ENV_RUN_ID: self.run_id, + _ENV_OUTPUT_DIR: self.output_dir, + _ENV_CACHE_DIR: self.cache_dir, + _ENV_ROLE: self.role.value, + } + if self.session_id is not None: + env[_ENV_SESSION_ID] = self.session_id + if self.task_id is not None: + env[_ENV_TASK_ID] = self.task_id + if self.otel_context is not None: + env[ENV_OTEL_TRACE_ID] = self.otel_context.trace_id + env[ENV_OTEL_SPAN_ID] = self.otel_context.span_id + return env + + @classmethod + def from_env(cls, env: dict[str, str] | None = None) -> Context: + src = env if env is not None else os.environ + run_id = src.get(_ENV_RUN_ID, "") + if not run_id: + raise RuntimeError(f"{_ENV_RUN_ID} not set in environment.") + run_id = sanitize_path_component(run_id) + output_dir = src.get(_ENV_OUTPUT_DIR) or get_settings().output_dir + cache_dir = src.get(_ENV_CACHE_DIR) or get_settings().cache_dir + session_id = src.get(_ENV_SESSION_ID) or None + task_id = src.get(_ENV_TASK_ID) or None + role_str = src.get(_ENV_ROLE) + try: + role = Role(role_str) if role_str else Role.FRAMEWORK + except ValueError: + role = Role.FRAMEWORK + + # Read OTEL context if present + otel_context: OtelContext | None = None + trace_id = src.get(ENV_OTEL_TRACE_ID) + span_id = src.get(ENV_OTEL_SPAN_ID) + if trace_id and span_id: + otel_context = OtelContext(trace_id=trace_id, span_id=span_id) + + return cls( + run_id=run_id, + output_dir=output_dir, + cache_dir=cache_dir, + session_id=session_id, + task_id=task_id, + role=role, + otel_context=otel_context, + ) + + +# --------------------------------------------------------------------------- +# Single ContextVar — the single source of truth +# --------------------------------------------------------------------------- + +_CONTEXT: contextvars.ContextVar[Context | None] = contextvars.ContextVar( + "exgentic_context", + default=None, +) + +# Fallback for threads that don't inherit ContextVar (uvicorn thread-pool +# workers, service runner threads). Set by init_context_from_env() and +# set_context_fallback(). +_SUBPROCESS_CONTEXT: Context | None = None + +_ENV_LOCK = threading.Lock() + + +# --------------------------------------------------------------------------- +# Accessors +# --------------------------------------------------------------------------- + + +def get_context() -> Context: + """Return the current Context. Raises RuntimeError if none is set.""" + ctx = _CONTEXT.get() + if ctx is None: + ctx = _SUBPROCESS_CONTEXT + if ctx is None: + raise RuntimeError("No context set. Use run_scope() or init_context_from_env().") + return ctx + + +def try_get_context() -> Context | None: + """Return the current Context, or None if none is set.""" + ctx = _CONTEXT.get() + return ctx if ctx is not None else _SUBPROCESS_CONTEXT + + +def context_env() -> dict[str, str]: + """Return context env vars for subprocess propagation, or empty dict.""" + ctx = try_get_context() + if ctx is None: + return {} + return ctx.to_env() + + +@contextmanager +def context_env_scope() -> Iterator[None]: + """Temporarily apply context env vars to os.environ (thread-safe).""" + env = context_env() + if not env: + yield + return + with _ENV_LOCK: + prev = {k: os.environ.get(k) for k in env} + os.environ.update(env) + try: + yield + finally: + for k, v in prev.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + +def set_context(ctx: Context) -> None: + """Imperatively set the current context.""" + _CONTEXT.set(ctx) + + +def set_context_fallback(ctx: Context | None) -> None: + """Set a process-wide fallback for threads that don't inherit ContextVar.""" + global _SUBPROCESS_CONTEXT + _SUBPROCESS_CONTEXT = ctx + + +# --------------------------------------------------------------------------- +# Context managers +# --------------------------------------------------------------------------- + + +@contextmanager +def run_scope( + ctx: Context | None = None, + *, + run_id: str | None = None, + output_dir: str | None = None, + cache_dir: str | None = None, + overwrite_run: bool = False, +) -> Iterator[Context]: + """Enter a run context. + + Either pass an explicit *ctx*, or pass keyword args and the Context will + be resolved from those args / env vars / settings defaults. + """ + if ctx is None: + ctx = _resolve_context(run_id, output_dir, cache_dir, overwrite_run) + token = _CONTEXT.set(ctx) + try: + yield ctx + finally: + _CONTEXT.reset(token) + + +@contextmanager +def session_scope(session_id: str, task_id: str | None = None) -> Iterator[Context]: + """Derive a session-scoped context from the current run context.""" + parent = get_context() + ctx = parent.with_session(session_id, task_id) + token = _CONTEXT.set(ctx) + try: + yield ctx + finally: + _CONTEXT.reset(token) + + +@contextmanager +def agent_scope() -> Iterator[Context]: + """Set role=AGENT for the duration of the block, restore on exit.""" + parent = get_context() + ctx = parent.with_role(Role.AGENT) + token = _CONTEXT.set(ctx) + try: + yield ctx + finally: + _CONTEXT.reset(token) + + +@contextmanager +def benchmark_scope() -> Iterator[Context]: + """Set role=BENCHMARK for the duration of the block, restore on exit.""" + parent = get_context() + ctx = parent.with_role(Role.BENCHMARK) + token = _CONTEXT.set(ctx) + try: + yield ctx + finally: + _CONTEXT.reset(token) + + +def init_context_from_env() -> Context: + """Bootstrap ContextVar from env vars (called once in subprocess / Docker).""" + global _SUBPROCESS_CONTEXT + ctx = Context.from_env() + _CONTEXT.set(ctx) + _SUBPROCESS_CONTEXT = ctx + return ctx + + +# --------------------------------------------------------------------------- +# Internals +# --------------------------------------------------------------------------- + + +def _resolve_context( + run_id: str | None, + output_dir: str | None, + cache_dir: str | None, + overwrite_run: bool, +) -> Context: + settings = get_settings() + resolved_run_id = run_id or os.environ.get(_ENV_RUN_ID) or datetime.now().isoformat().replace(":", "--") + resolved_run_id = sanitize_path_component(resolved_run_id) + resolved_output_dir = output_dir or os.environ.get(_ENV_OUTPUT_DIR) or settings.output_dir + resolved_output_dir = str(Path(resolved_output_dir).resolve()) + resolved_cache_dir = cache_dir or os.environ.get(_ENV_CACHE_DIR) or settings.cache_dir + resolved_cache_dir = str(Path(resolved_cache_dir).resolve()) + if overwrite_run: + run_root = Path(resolved_output_dir) / resolved_run_id + if run_root.exists(): + shutil.rmtree(run_root) + return Context( + run_id=resolved_run_id, + output_dir=resolved_output_dir, + cache_dir=resolved_cache_dir, + ) diff --git a/labs/AgentStream/exgentic/src/exgentic/core/evaluator.py b/labs/AgentStream/exgentic/src/exgentic/core/evaluator.py new file mode 100644 index 00000000..bf633060 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/core/evaluator.py @@ -0,0 +1,53 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Evaluator ABC — benchmark evaluation logic (task discovery, session config, aggregation). + +An Evaluator is created via ``Benchmark.get_evaluator()`` for container +isolation, keeping heavy dependencies off the host. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + +from ..utils.paths import SessionPaths, get_run_paths +from .types import BenchmarkResults, SessionIndex + + +class Evaluator(ABC): + """Benchmark evaluation logic — task discovery, session config, aggregation. + + Runs in the same isolation level as the benchmark's runner (can be containerized). + Returns only simple serializable data across the transport boundary. + """ + + @abstractmethod + def list_tasks(self) -> list[str]: + """Return available task identifiers for this benchmark.""" + ... + + @abstractmethod + def get_session_kwargs(self, index: SessionIndex) -> dict[str, Any]: + """Return kwargs for constructing the Session for a given task. + + The orchestrator will call:: + + benchmark.get_session(**session_kwargs) + """ + ... + + @abstractmethod + def aggregate_sessions(self, sessions: list[SessionIndex]) -> BenchmarkResults: + """Aggregate results for the specified task sessions.""" + ... + + def get_sessions_paths(self, sessions: list[SessionIndex]) -> list[SessionPaths]: + """Return ``SessionPaths`` for each session index.""" + run_paths = get_run_paths() + return [run_paths.session(s.session_id) for s in sessions] + + def close(self) -> None: + """Optional cleanup hook.""" + return diff --git a/labs/AgentStream/exgentic/src/exgentic/core/orchestrator/__init__.py b/labs/AgentStream/exgentic/src/exgentic/core/orchestrator/__init__.py new file mode 100644 index 00000000..ee25075a --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/core/orchestrator/__init__.py @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from .controller import Controller, CoreController, LimitController +from .observer import Observer +from .run import core_aggregate, core_evaluate, core_execute +from .session import run_session +from .termination import ( + AgentError, + AgentTerminationError, + BenchmarkError, + BenchmarkTerminationError, + InvalidActionError, + InvalidObservationError, + RunCancelError, + SessionCancelError, + SessionLimitReachedError, +) +from .tracker import Tracker + +__all__ = [ + "AgentError", + "AgentTerminationError", + "BenchmarkError", + "BenchmarkTerminationError", + "Controller", + "CoreController", + "LimitController", + "InvalidActionError", + "InvalidObservationError", + "Observer", + "RunCancelError", + "Tracker", + "SessionCancelError", + "SessionLimitReachedError", + "SessionTermination", + "run_session", + "core_aggregate", + "core_execute", + "core_evaluate", +] diff --git a/labs/AgentStream/exgentic/src/exgentic/core/orchestrator/cleanup.py b/labs/AgentStream/exgentic/src/exgentic/core/orchestrator/cleanup.py new file mode 100644 index 00000000..baf9627f --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/core/orchestrator/cleanup.py @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import asyncio +import gc + + +def close_aiohttp_sessions_silently() -> None: + """Best-effort cleanup for stray aiohttp sessions to avoid resource warnings.""" + try: + import aiohttp # type: ignore + except Exception: + return + + sessions = [obj for obj in gc.get_objects() if isinstance(obj, aiohttp.ClientSession) and not obj.closed] + if not sessions: + return + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(asyncio.gather(*(s.close() for s in sessions), return_exceptions=True)) + asyncio.set_event_loop(None) + loop.close() diff --git a/labs/AgentStream/exgentic/src/exgentic/core/orchestrator/controller.py b/labs/AgentStream/exgentic/src/exgentic/core/orchestrator/controller.py new file mode 100644 index 00000000..a9724ba0 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/core/orchestrator/controller.py @@ -0,0 +1,114 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import threading +from dataclasses import dataclass + +from ..types import Action, Observation +from .cleanup import close_aiohttp_sessions_silently +from .termination import ( + AgentError, + AgentTerminationError, + BenchmarkError, + BenchmarkTerminationError, + InvalidActionError, + InvalidObservationError, +) + + +class Controller: + def on_run_success(self, results, run_config) -> None: + return None + + def on_run_error(self, error) -> None: + return None + + def on_react_success(self, session, action) -> None: + return None + + def on_step_success(self, session, observation) -> None: + return None + + def on_react_error(self, session, error) -> None: + return None + + def on_step_error(self, session, error) -> None: + return None + + +class CoreController(Controller): + def on_react_success(self, session, action) -> None: + if action is None: + raise BenchmarkTerminationError() + if not isinstance(action, Action): + raise AgentError(InvalidActionError(action)) + + def on_step_success(self, session, observation) -> None: + if observation is None: + raise AgentTerminationError() + if not isinstance(observation, Observation): + raise BenchmarkError(InvalidObservationError(observation)) + + +@dataclass +class _LimitState: + steps: int = 0 + actions: int = 0 + + +class LimitController(Controller): + def __init__(self, *, max_steps: int, max_actions: int) -> None: + self._max_steps = max_steps + self._max_actions = max_actions + self._lock = threading.Lock() + self._counts: dict[str, _LimitState] = {} + + def on_react_success(self, session, action) -> None: + if not isinstance(action, Action): + return + session_id = session.session_id + action_count = len(action.to_action_list()) + with self._lock: + state = self._counts.get(session_id) + if state is None: + state = _LimitState() + self._counts[session_id] = state + state.steps += 1 + state.actions += action_count + + def on_step_success(self, session, observation) -> None: + if observation is None: + return + session_id = session.session_id + with self._lock: + state = self._counts.get(session_id) + if state is None: + return + if state.steps >= self._max_steps: + from .termination import SessionLimitReachedError + + raise SessionLimitReachedError( + reason="max_steps", + max_steps=self._max_steps, + max_actions=self._max_actions, + steps=state.steps, + actions=state.actions, + ) + if state.actions >= self._max_actions: + from .termination import SessionLimitReachedError + + raise SessionLimitReachedError( + reason="max_actions", + max_steps=self._max_steps, + max_actions=self._max_actions, + steps=state.steps, + actions=state.actions, + ) + + +class CleanupController(Controller): + def on_run_success(self, results, run_config) -> None: + close_aiohttp_sessions_silently() + + def on_run_error(self, error) -> None: + close_aiohttp_sessions_silently() diff --git a/labs/AgentStream/exgentic/src/exgentic/core/orchestrator/execution.py b/labs/AgentStream/exgentic/src/exgentic/core/orchestrator/execution.py new file mode 100644 index 00000000..5554c2e6 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/core/orchestrator/execution.py @@ -0,0 +1,418 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import json +import shutil +from collections import deque +from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait +from contextvars import copy_context +from pathlib import Path + +from filelock import FileLock, Timeout + +from ...interfaces.registry import load_agent, load_benchmark +from ...observers.logging import get_disabled_logger +from ...utils.paths import get_run_paths, get_session_paths +from ..types import ( + SessionConfig, + SessionExecutionStatus, + SessionIndex, + SessionOutcomeStatus, + SessionResults, + SessionStatus, +) +from .session import run_session +from .termination import RunCancelError +from .tracker import Tracker + +_BENCHMARK_CACHE: dict[str, type] = {} +_AGENT_CACHE: dict[str, type] = {} + + +def _get_benchmark_class(slug: str): + cls = _BENCHMARK_CACHE.get(slug) + if cls is None: + cls = load_benchmark(slug) + _BENCHMARK_CACHE[slug] = cls + return cls + + +def _get_agent_class(slug: str): + cls = _AGENT_CACHE.get(slug) + if cls is None: + cls = load_agent(slug) + _AGENT_CACHE[slug] = cls + return cls + + +def _try_reuse_completed( + *, + status: SessionStatus, + session_config: SessionConfig, + sess_paths, + tracker: Tracker, + log, +) -> bool: + if status.status != SessionExecutionStatus.COMPLETED: + return False + if session_config.overwrite_sessions: + return False + results_path = sess_paths.results + if not results_path.exists(): + return False + try: + payload = json.loads(results_path.read_text(encoding="utf-8")) + results = SessionResults.model_validate(payload) + tracker.on_session_reuse(results) + log.info( + "Skipping completed session %s (task=%s)", + session_config.get_session_id(), + session_config.task_id, + ) + return True + except Exception: + log.exception( + "Failed to load results for session %s (task=%s); rerunning.", + session_config.get_session_id(), + session_config.task_id, + ) + return False + + +def _cleanup_session_dir( + *, + status: SessionStatus, + session_config: SessionConfig, + sess_paths, + log, +) -> None: + if session_config.overwrite_sessions and sess_paths.root.exists(): + shutil.rmtree(sess_paths.root) + log.info( + "Overwriting existing session %s (task=%s)", + session_config.get_session_id(), + session_config.task_id, + ) + return + if status.status == SessionExecutionStatus.INCOMPLETE and sess_paths.root.exists(): + shutil.rmtree(sess_paths.root) + log.info( + "Overwriting incomplete session %s (task=%s)", + session_config.get_session_id(), + session_config.task_id, + ) + + +def _write_session_config( + *, + session_config: SessionConfig, + sess_paths, +) -> None: + config_path = sess_paths.session_config + config_path.parent.mkdir(parents=True, exist_ok=True) + with open(config_path, "w", encoding="utf-8") as f: + json.dump( + session_config.model_dump(mode="json"), + f, + ensure_ascii=False, + indent=2, + ) + + +def _status_from_paths_without_lock( + *, + session_config: SessionConfig, + sess_paths, +) -> SessionStatus: + """Derive session status from filesystem without consulting lock state.""" + results_exists = sess_paths.results.exists() + session_dir_exists = sess_paths.root.exists() + result_status = None + + if results_exists: + result_status = SessionStatus._extract_result_status(sess_paths.results) + if result_status in ( + SessionOutcomeStatus.ERROR, + SessionOutcomeStatus.CANCELLED, + SessionOutcomeStatus.LIMIT_REACHED, + ): + status = SessionExecutionStatus.INCOMPLETE + else: + status = SessionExecutionStatus.COMPLETED + elif session_dir_exists: + status = SessionExecutionStatus.INCOMPLETE + else: + status = SessionExecutionStatus.MISSING + + return SessionStatus( + task_id=str(session_config.task_id), + session_id=session_config.get_session_id(), + results_path=str(sess_paths.results), + session_dir=str(sess_paths.root), + status=status, + result_status=result_status, + ) + + +def run_session_config( + *, + session_config: SessionConfig, + tracker: Tracker, +) -> None: + bench_cls = _get_benchmark_class(session_config.benchmark) + agent_cls = _get_agent_class(session_config.agent) + benchmark = bench_cls(**(session_config.benchmark_kwargs or {})) + agent = agent_cls(**(session_config.agent_kwargs or {})) + + # Create evaluator to obtain session kwargs. + evaluator = benchmark.get_evaluator() + + session_id = session_config.get_session_id() + index = SessionIndex( + task_id=str(session_config.task_id), + session_id=session_id, + ) + + try: + session_kwargs = evaluator.get_session_kwargs(index) + # Create session via runner for isolation. + session = benchmark.get_session(**session_kwargs) + # run_session handles session.close() internally. + run_session(session_config, session, agent, tracker=tracker) + finally: + try: + evaluator.close() + except Exception: + pass + try: + benchmark.close() + finally: + agent.close() + + +def _run_task_with_lock( + *, + session_config: SessionConfig, + tracker: Tracker, + log, +) -> None: + session_id = session_config.get_session_id() + sess_paths = get_session_paths(session_id) + sess_paths.root.mkdir(parents=True, exist_ok=True) + status = SessionStatus.from_config(session_config) + lock = FileLock(str(sess_paths.lock)) + try: + lock.acquire(timeout=0) + except Timeout: + log.info( + "Skipping running session %s (task=%s)", + session_id, + session_config.task_id, + ) + return + try: + # If status was sampled while another process held the lock, refresh it + # after acquiring the lock so cleanup/reuse decisions stay accurate. + if status.status == SessionExecutionStatus.RUNNING: + status = _status_from_paths_without_lock(session_config=session_config, sess_paths=sess_paths) + if _try_reuse_completed( + status=status, + session_config=session_config, + sess_paths=sess_paths, + tracker=tracker, + log=log, + ): + return + _cleanup_session_dir( + status=status, + session_config=session_config, + sess_paths=sess_paths, + log=log, + ) + + _write_session_config(session_config=session_config, sess_paths=sess_paths) + run_session_config(session_config=session_config, tracker=tracker) + finally: + if lock.is_locked: + lock.release() + + +_TRANSIENT_ERROR_PATTERNS = ( + "must have either content or tool calls", + "AssistantMessage must have", + "object has no attribute 'session_id'", +) + +_MAX_SESSION_RETRIES = 2 + + +def _is_transient_error(exc: Exception) -> bool: + """Check if an exception is a known transient benchmark error worth retrying.""" + msg = str(exc) + return any(pat in msg for pat in _TRANSIENT_ERROR_PATTERNS) + + +def _execute_sessions_serial( + *, + session_configs: list[SessionConfig], + tracker: Tracker, + log, +) -> bool: + had_error = False + try: + for session_config in session_configs: + succeeded = False + for attempt in range(_MAX_SESSION_RETRIES + 1): + try: + _run_task_with_lock( + session_config=session_config, + tracker=tracker, + log=log, + ) + succeeded = True + break + except (KeyboardInterrupt, RunCancelError): + raise + except Exception as exc: + if attempt < _MAX_SESSION_RETRIES and _is_transient_error(exc): + log.warning( + "Transient error on task=%s (attempt %d/%d), retrying: %s", + session_config.task_id if session_config else "unknown", + attempt + 1, + _MAX_SESSION_RETRIES + 1, + exc, + ) + continue + log.exception( + "Session task failed task=%s", + session_config.task_id if session_config else "unknown", + ) + had_error = True + break + if not succeeded and not had_error: + had_error = True + except (KeyboardInterrupt, RunCancelError) as exc: + tracker.on_run_error(exc) + had_error = True + return had_error + + +def _execute_sessions_parallel( + *, + session_configs: list[SessionConfig], + tracker: Tracker, + max_workers: int, + log, +) -> bool: + had_error = False + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures: dict = {} + pending_tasks = deque(session_configs) + try: + while futures or pending_tasks: + while len(futures) < max_workers: + if not pending_tasks: + break + session_config = pending_tasks.popleft() + ctx = copy_context() + future = executor.submit( + ctx.run, + _run_task_with_lock, + session_config=session_config, + tracker=tracker, + log=log, + ) + futures[future] = session_config + + if futures: + done_set, _ = wait(futures, return_when=FIRST_COMPLETED) + for done in done_set: + session_config = futures.pop(done, None) + try: + done.result() + except RunCancelError as exc: + tracker.on_run_error(exc) + had_error = True + raise + except Exception as exc: + session_id = session_config.get_session_id() if session_config is not None else "unknown" + log.exception( + "Session task failed task=%s session=%s", + session_config.task_id if session_config else "unknown", + session_id, + ) + tracker.on_run_error(exc) + had_error = True + except (KeyboardInterrupt, RunCancelError) as exc: + tracker.on_run_error(exc) + had_error = True + for future in futures: + future.cancel() + return had_error + + +def execute_sessions( + *, + session_configs: list[SessionConfig], + tracker: Tracker, + reused_results: list[SessionResults] | None = None, + max_workers: int | None = None, + log=None, +) -> bool: + if log is None: + log = get_disabled_logger(__name__) + if reused_results: + for item in reused_results: + tracker.on_session_reuse(item) + + if max_workers and max_workers > 1: + return _execute_sessions_parallel( + session_configs=session_configs, + tracker=tracker, + max_workers=max_workers, + log=log, + ) + return _execute_sessions_serial( + session_configs=session_configs, + tracker=tracker, + log=log, + ) + + +def load_session_results( + results_path: Path, + session_id: str, + log, +) -> SessionResults | None: + try: + payload = json.loads(results_path.read_text(encoding="utf-8")) + return SessionResults.model_validate(payload) + except Exception: + log.exception( + "Failed to load session results for %s at %s", + session_id, + results_path, + ) + return None + + +def load_reused_results( + session_configs: list[SessionConfig], + log, +) -> list[SessionResults]: + run_paths = get_run_paths() + reused: list[SessionResults] = [] + for session_config in session_configs: + session_id = session_config.get_session_id() + results_path = run_paths.session(session_id).results + if not results_path.exists(): + continue + results = load_session_results(results_path, session_id, log) + if results is not None: + reused.append(results) + log.info( + "Skipping existing session %s (task=%s)", + session_id, + session_config.task_id, + ) + return reused diff --git a/labs/AgentStream/exgentic/src/exgentic/core/orchestrator/observer.py b/labs/AgentStream/exgentic/src/exgentic/core/orchestrator/observer.py new file mode 100644 index 00000000..7ee00845 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/core/orchestrator/observer.py @@ -0,0 +1,68 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from ...utils.paths import RunPaths + + +class Observer: + events = None + + def __init__(self, run_id: str | None = None) -> None: + self._run_id = run_id + self._paths: RunPaths | None = None + + @property + def paths(self) -> RunPaths: + if self._paths is None: + from ..context import get_context + + ctx = get_context() + if self._run_id is not None: + self._paths = RunPaths(run_id=self._run_id, output_dir=ctx.output_dir) + else: + self._paths = RunPaths.from_context(ctx) + self._run_id = self._paths.run_id + return self._paths + + def on_run_start(self, run_config) -> None: + return None + + def on_run_success(self, results, run_config) -> None: + return None + + def on_run_error(self, error) -> None: + return None + + def on_session_creation(self, session) -> None: + return None + + def on_session_start(self, session, agent, observation) -> None: + return None + + def on_react_success(self, session, action) -> None: + return None + + def on_step_success(self, session, observation) -> None: + return None + + def on_react_error( + self, + session, + error, + ) -> None: + return None + + def on_step_error(self, session, error) -> None: + return None + + def on_session_error(self, session, error) -> None: + return None + + def on_session_success(self, session, score, agent) -> None: + return None + + def on_session_scoring(self, session) -> None: + return None + + def on_session_reuse(self, task_result) -> None: + return None diff --git a/labs/AgentStream/exgentic/src/exgentic/core/orchestrator/run.py b/labs/AgentStream/exgentic/src/exgentic/core/orchestrator/run.py new file mode 100644 index 00000000..5542cca5 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/core/orchestrator/run.py @@ -0,0 +1,190 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from ...interfaces.registry import load_benchmark +from ...observers.logging import get_logger +from ...utils.paths import get_run_paths +from ..types import ( + RunConfig, + RunPlan, + RunResults, + RunStatus, + SessionExecutionStatus, + SessionIndex, +) +from .controller import Controller +from .execution import execute_sessions, load_reused_results +from .observer import Observer +from .tracker import Tracker + + +def _build_session_indexes(run_config: RunConfig, task_ids: list[str]): + return [run_config.to_session_config(task_id).to_index() for task_id in task_ids] + + +def _log_missing_session_results( + *, + run_config: RunConfig, + task_ids: list[str], + log, +) -> None: + if not task_ids: + return + missing: list[str] = [] + run_paths = get_run_paths() + for task_id in task_ids: + session_id = run_config.to_session_config(task_id).get_session_id() + if not run_paths.session(session_id).results.exists(): + missing.append(session_id) + if not missing: + return + count = len(missing) + total = len(task_ids) + log.warning("Missing session results for %d/%d planned sessions.", count, total) + preview = ", ".join(missing[:10]) + if count > 10: + preview = f"{preview}, ..." + log.warning("Missing session ids: %s", preview) + + +def core_run( + *, + run_config: RunConfig, + observers: list[Observer] | None = None, + controllers: list[Controller] | None = None, + execute: bool, + aggregate: bool, +) -> RunResults: + with run_config.get_context() as ctx: + if run_config.run_id is None or run_config.cache_dir is None: + updates = {} + if run_config.run_id is None: + updates["run_id"] = ctx.run_id + if run_config.cache_dir is None: + updates["cache_dir"] = ctx.cache_dir + run_config = run_config.model_copy(update=updates) + if execute: + tracker = Tracker( + observers=observers, + controllers=controllers, + max_steps=run_config.max_steps, + max_actions=run_config.max_actions, + ) + else: + tracker = Tracker(observers=observers, controllers=controllers) + run_paths = get_run_paths() + log = get_logger( + f"tracker.{run_paths.run_id}", + str(run_paths.tracker), + ) + status = RunStatus.from_config(run_config) + if run_config.task_ids is None and status.task_ids: + run_config = run_config.model_copy(update={"task_ids": status.task_ids}) + tracker.on_run_start(run_config) + if execute: + plan = RunPlan.from_config_and_status(run_config, status) + reused_results = load_reused_results(plan.reuse, log) + log.info( + "Session selection: total=%d to_run=%d skipped=%d", + len(status.task_ids), + len(plan.to_run), + len(plan.reuse), + ) + had_error = execute_sessions( + session_configs=plan.to_run, + tracker=tracker, + reused_results=reused_results, + max_workers=run_config.max_workers, + log=log, + ) + if had_error: + return tracker.results() + else: + reused_results = load_reused_results( + [run_config.to_session_config(task_id) for task_id in status.task_ids], + log, + ) + for item in reused_results: + tracker.on_session_reuse(item) + + results = None + if aggregate: + if execute: + status = RunStatus.from_config(run_config) + if status.task_ids: + _log_missing_session_results(run_config=run_config, task_ids=status.task_ids, log=log) + # Aggregate only completed sessions. + completed = [item for item in status.session_statuses if item.status == SessionExecutionStatus.COMPLETED] + if completed: + session_indexes = [SessionIndex(task_id=item.task_id, session_id=item.session_id) for item in completed] + else: + session_indexes = [] + skipped = len(status.session_statuses) - len(session_indexes) + if skipped: + log.warning( + "Skipping %d non-completed sessions during aggregation.", + skipped, + ) + if not session_indexes: + log.warning("No completed sessions available for aggregation.") + # Create evaluator for aggregation. + bench_cls = load_benchmark(run_config.benchmark) + benchmark = bench_cls(**(run_config.benchmark_kwargs or {})) + evaluator = benchmark.get_evaluator() + try: + results = evaluator.aggregate_sessions(session_indexes) + finally: + try: + evaluator.close() + except Exception: + pass + benchmark.close() + tracker.on_run_success(results, run_config) + return tracker.results() + + +def core_execute( + *, + run_config: RunConfig, + observers: list[Observer] | None = None, + controllers: list[Controller] | None = None, +) -> RunResults: + return core_run( + run_config=run_config, + observers=observers, + controllers=controllers, + execute=True, + aggregate=False, + ) + + +def core_evaluate( + *, + run_config: RunConfig, + observers: list[Observer] | None = None, + controllers: list[Controller] | None = None, +) -> RunResults: + return core_run( + run_config=run_config, + observers=observers, + controllers=controllers, + execute=True, + aggregate=True, + ) + + +def core_aggregate( + *, + run_config: RunConfig, + observers: list[Observer] | None = None, + controllers: list[Controller] | None = None, +) -> RunResults: + return core_run( + run_config=run_config, + observers=observers, + controllers=controllers, + execute=False, + aggregate=True, + ) diff --git a/labs/AgentStream/exgentic/src/exgentic/core/orchestrator/session.py b/labs/AgentStream/exgentic/src/exgentic/core/orchestrator/session.py new file mode 100644 index 00000000..41b7f9fe --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/core/orchestrator/session.py @@ -0,0 +1,129 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from ..context import agent_scope, benchmark_scope, session_scope +from ..types import SessionConfig +from .controller import Controller +from .observer import Observer +from .termination import ( + AgentError, + AgentTerminationError, + BenchmarkError, + BenchmarkTerminationError, + RunCancelError, + SessionCancelError, + SessionLimitReachedError, +) +from .tracker import Tracker + + +def _close_session_agent(session, agent_instance) -> None: + session.close() + agent_instance.close() + + +def run_session( + session_config: SessionConfig, + session, + agent, + observers: list[Observer] | None = None, + controllers: list[Controller] | None = None, + *, + tracker: Tracker | None = None, +) -> None: + """Process a single session. + + The *agent_instance* is created via ``agent.get_instance()`` for + runner isolation. + """ + if tracker is None: + tracker = Tracker(observers=observers, controllers=controllers) + + with session_scope(session.session_id, task_id=session.task_id): + agent_instance = agent.get_instance(session_id=session.session_id) + + with benchmark_scope(): + observation = session.start() + + with agent_scope(): + agent_instance.start( + task=session.task, + context=session.context, + actions=session.actions, + ) + + try: + tracker.on_session_start(session, agent_instance, observation) + while not session.done() and observation is not None: + try: + with agent_scope(): + action = agent_instance.react(observation) + except Exception as exc: + tracker.on_react_error(session, exc) + + tracker.on_react_success( + session, + action, + ) + + try: + with benchmark_scope(): + observation = session.step(action) + except Exception as exc: + tracker.on_step_error( + session, + exc, + ) + + tracker.on_step_success( + session, + observation, + ) + + if session.done(): + raise BenchmarkTerminationError() + raise AgentTerminationError() + + except KeyboardInterrupt: + tracker.on_session_error(session, RunCancelError()) + _close_session_agent(session, agent_instance) + raise + except AgentError as exc: + tracker.on_session_error(session, exc) + _close_session_agent(session, agent_instance) + except SessionLimitReachedError as exc: + with benchmark_scope(): + tracker.on_session_scoring(session) + with benchmark_scope(): + score = session.score() + score.is_finished = False + score.session_metadata = { + **(score.session_metadata or {}), + "limit_reached": True, + "limit_reason": exc.reason, + "max_steps": exc.max_steps, + "max_actions": exc.max_actions, + "steps": exc.steps, + "actions": exc.actions, + } + tracker.on_session_success(session, score, agent_instance) + _close_session_agent(session, agent_instance) + except (AgentTerminationError, BenchmarkTerminationError): + with benchmark_scope(): + tracker.on_session_scoring(session) + with benchmark_scope(): + score = session.score() + if score.is_finished is None: + score.is_finished = True + tracker.on_session_success(session, score, agent_instance) + _close_session_agent(session, agent_instance) + except BenchmarkError as exc: + tracker.on_session_error(session, exc) + agent_instance.close() + except SessionCancelError as exc: + tracker.on_session_error(session, exc) + _close_session_agent(session, agent_instance) + except RunCancelError as exc: + tracker.on_session_error(session, exc) + _close_session_agent(session, agent_instance) + raise diff --git a/labs/AgentStream/exgentic/src/exgentic/core/orchestrator/termination.py b/labs/AgentStream/exgentic/src/exgentic/core/orchestrator/termination.py new file mode 100644 index 00000000..6b1ea85b --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/core/orchestrator/termination.py @@ -0,0 +1,71 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + + +class SessionTerminationError(Exception): + """Internal control-flow signal for clean session termination.""" + + +class SessionCancelError(SessionTerminationError): + pass + + +class RunCancelError(SessionTerminationError): + pass + + +class AgentTerminationError(SessionTerminationError): + pass + + +class BenchmarkTerminationError(SessionTerminationError): + pass + + +class SessionLimitReachedError(SessionTerminationError): + def __init__( + self, + *, + reason: str, + max_steps: int, + max_actions: int, + steps: int, + actions: int, + ) -> None: + super().__init__() + self.reason = reason + self.max_steps = max_steps + self.max_actions = max_actions + self.steps = steps + self.actions = actions + + def __str__(self) -> str: + return ( + f"limit_reached ({self.reason}): " + f"steps={self.steps}/{self.max_steps}, " + f"actions={self.actions}/{self.max_actions}" + ) + + +class AgentError(SessionTerminationError): + def __init__(self, error: Exception | None = None) -> None: + super().__init__() + self.error = error + + +class BenchmarkError(SessionTerminationError): + def __init__(self, error: Exception | None = None) -> None: + super().__init__() + self.error = error + + +class InvalidActionError(ValueError): + def __init__(self, action) -> None: + self.action = action + super().__init__(f"illegal action returned from agent: {action}") + + +class InvalidObservationError(ValueError): + def __init__(self, observation) -> None: + self.observation = observation + super().__init__(f"illegal observation returned from session: {observation}") diff --git a/labs/AgentStream/exgentic/src/exgentic/core/orchestrator/tracker.py b/labs/AgentStream/exgentic/src/exgentic/core/orchestrator/tracker.py new file mode 100644 index 00000000..a320ebe5 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/core/orchestrator/tracker.py @@ -0,0 +1,176 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import threading +from typing import Iterable + +from ...observers.handlers.configs import ConfigsObserver +from ...observers.handlers.file_logger import FileLoggerObserver +from ...observers.handlers.logger import ConsoleLoggerObserver +from ...observers.handlers.recap import RunRecapObserver +from ...observers.handlers.results import ResultsObserver +from ...observers.handlers.warnings import WarningsObserver +from ...utils.settings import get_settings +from ..context import get_context +from .controller import CleanupController, Controller, CoreController, LimitController +from .observer import Observer +from .termination import AgentError, BenchmarkError + + +class Tracker(Observer, Controller): + def __init__( + self, + *, + observers: Iterable[Observer] | None = None, + controllers: Iterable[Controller] | None = None, + use_defaults: bool = True, + max_steps: int = 100, + max_actions: int = 100, + ) -> None: + self._run_id = get_context().run_id + self._observers: list[Observer] = [] + self._controllers: list[Controller] = [] + self._results: ResultsObserver | None = None + self.events = None + self._run_lock = threading.Lock() + self._run_state: str | None = None + if use_defaults: + self._register_observer(ResultsObserver()) + self._register_observer(ConfigsObserver()) + self._register_observer(WarningsObserver()) + self._register_observer(FileLoggerObserver()) + self._register_observer(ConsoleLoggerObserver()) + self._register_observer(RunRecapObserver(console=False)) + if get_settings().otel_enabled: + from ...observers.handlers.otel import OtelTracingObserver + + self._register_observer(OtelTracingObserver()) + self._register_controller(LimitController(max_steps=max_steps, max_actions=max_actions)) + self._register_controller(CleanupController()) + if observers: + for observer in observers: + self._register_observer(observer) + if controllers: + for controller in controllers: + self._register_controller(controller) + self._ensure_core_controller() + + @property + def run_id(self) -> str: + return self._run_id + + @property + def session_results(self): + if self._results is not None: + return self._results.session_results() + return [] + + def results(self): + if self._results is not None: + return self._results.results() + raise RuntimeError("No results observer configured to provide results.") + + def _register_observer(self, observer: Observer | None) -> None: + if observer is None: + return + if observer in self._observers: + return + if isinstance(observer, ResultsObserver): + self._results = observer + if observer.events is not None: + self.events = observer.events + self._observers.append(observer) + + def _register_controller(self, controller: Controller | None) -> None: + if controller is None: + return + if controller in self._controllers: + return + self._controllers.append(controller) + + def on_run_start(self, run_config) -> None: + for observer in self._observers: + observer.on_run_start(run_config) + + def on_run_success(self, results, run_config) -> None: + if not self._mark_run_final("success"): + return + for observer in self._observers: + observer.on_run_success(results, run_config) + for controller in self._controllers: + controller.on_run_success(results, run_config) + + def on_run_error(self, error) -> None: + if not self._mark_run_final("error"): + return + for observer in self._observers: + observer.on_run_error(error) + for controller in self._controllers: + controller.on_run_error(error) + + def on_session_creation(self, session) -> None: + for observer in self._observers: + observer.on_session_creation(session) + + def on_session_start(self, session, agent, observation) -> None: + for observer in self._observers: + observer.on_session_start(session, agent, observation) + + def on_react_success(self, session, action) -> None: + for observer in self._observers: + observer.on_react_success(session, action) + for controller in self._controllers: + controller.on_react_success(session, action) + + def on_step_success(self, session, observation) -> None: + for observer in self._observers: + observer.on_step_success(session, observation) + for controller in self._controllers: + controller.on_step_success(session, observation) + + def on_react_error(self, session, error) -> None: + for observer in self._observers: + observer.on_react_error(session, error) + for controller in self._controllers: + controller.on_react_error(session, error) + raise AgentError(error) + + def on_step_error(self, session, error) -> None: + for observer in self._observers: + observer.on_step_error(session, error) + for controller in self._controllers: + controller.on_step_error(session, error) + raise BenchmarkError(error) + + def on_session_error(self, session, error) -> None: + for observer in self._observers: + observer.on_session_error(session, error) + + def on_session_success(self, session, score, agent) -> None: + for observer in self._observers: + observer.on_session_success(session, score, agent) + + def on_session_scoring(self, session) -> None: + for observer in self._observers: + observer.on_session_scoring(session) + + def on_session_reuse(self, session_results) -> None: + for observer in self._observers: + observer.on_session_reuse(session_results) + + def _ensure_core_controller(self) -> None: + cores = [item for item in self._controllers if isinstance(item, CoreController)] + non_cores = [item for item in self._controllers if not isinstance(item, CoreController)] + if not cores: + self._controllers = [*non_cores, CoreController()] + return + self._controllers = [*non_cores, cores[-1]] + + def _mark_run_final(self, state: str) -> bool: + with self._run_lock: + if self._run_state is not None: + return False + self._run_state = state + return True diff --git a/labs/AgentStream/exgentic/src/exgentic/core/runner_mixin.py b/labs/AgentStream/exgentic/src/exgentic/core/runner_mixin.py new file mode 100644 index 00000000..fbb80c60 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/core/runner_mixin.py @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from pathlib import Path +from typing import Any + +from ..utils.settings import RunnerName, get_settings +from .context import try_get_context + + +class RunnerMixin: + """Shared runner/Docker logic for Agent and Benchmark.""" + + runner: RunnerName | None + docker_socket: bool + slug_name: str + + def resolve_runner(self) -> RunnerName: + """Resolve the runner name from ``runner`` field or settings default.""" + if self.runner is not None: + return self.runner + return get_settings().default_runner + + def runner_kwargs(self) -> dict[str, Any]: + """Return extra kwargs for ``with_runner()`` when runner is docker or venv.""" + runner = self.resolve_runner() + + kind = "agents" if self._is_agent() else "benchmarks" + + if runner == "venv": + return { + "env_name": f"{kind}/{self.slug_name}", + "module_path": type(self).__module__, + } + + if runner != "docker": + return {} + + kw: dict[str, Any] = { + "env_name": f"{kind}/{self.slug_name}", + "module_path": type(self).__module__, + } + if self.docker_socket: + kw["docker_socket"] = True + ctx = try_get_context() + output_dir = ctx.output_dir if ctx is not None else get_settings().output_dir + output_dir = str(Path(output_dir).resolve()) + kw["volumes"] = {output_dir: output_dir} + return kw + + def _is_agent(self) -> bool: + """Return True if this instance is an Agent (not a Benchmark).""" + from .agent import Agent + + return isinstance(self, Agent) + + def close(self) -> None: + """Optional cleanup hook.""" + return diff --git a/labs/AgentStream/exgentic/src/exgentic/core/session.py b/labs/AgentStream/exgentic/src/exgentic/core/session.py new file mode 100644 index 00000000..fab80472 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/core/session.py @@ -0,0 +1,161 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import json +import secrets +from abc import ABC, abstractmethod +from logging import Logger +from typing import Any, Dict, List, Optional + +from ..observers.logging import get_logger +from ..utils.cost import CostReport +from ..utils.paths import SessionPaths +from .types import Action, ActionType, Observation, SessionScore + + +class Session(ABC): + """Session interface - represents one task execution in one environment.""" + + def __init__(self) -> None: + # Persist configuration once per session if provided by subclass. + self.save_config() + self.save_manifest() + + @property + def session_id(self) -> str: + """Returns a unique id for the session.""" + if not hasattr(self, "_session_id"): + self._session_id = secrets.token_hex(4) + return self._session_id + + @property + def paths(self) -> SessionPaths: + """Convenience wrapper for all filesystem paths for this session.""" + if not hasattr(self, "_paths"): + from .context import try_get_context + + ctx = try_get_context() + if ctx is not None: + self._paths = SessionPaths( + session_id=self.session_id, + run_id=ctx.run_id, + output_dir=ctx.output_dir, + ) + else: + self._paths = SessionPaths(session_id=self.session_id, run_id="default", output_dir="outputs") + return self._paths + + @property + def logger(self) -> Logger: + if not hasattr(self, "_logger"): + self._logger = get_logger(f"Session_{self.session_id}", str(self.paths.session_log)) + return self._logger + + def get_config(self) -> Dict[str, Any]: + """Return a serializable configuration dict for this session.""" + return {} + + def save_config(self) -> None: + """Persist the session configuration to the standard config path.""" + config = self.get_config() + config_path = self.paths.benchmark_config + config_path.parent.mkdir(parents=True, exist_ok=True) + with open(config_path, "w", encoding="utf-8") as f: + json.dump(config, f, ensure_ascii=False, indent=2) + + def save_results(self, payload: Dict[str, Any]) -> None: + """Persist a results payload to the standard per-session results path.""" + results_path = self.paths.benchmark_results + results_path.parent.mkdir(parents=True, exist_ok=True) + with open(results_path, "w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, indent=2) + + def save_standard_results(self, score: SessionScore) -> None: + """Persist the minimal standardized results payload (score/success).""" + self.save_results({"score": score.score, "success": bool(score.success)}) + + def save_manifest(self) -> None: + """Persist task/context/actions metadata for the session.""" + actions_payload = [] + for action_type in self.actions: + action_entry = { + "name": action_type.name, + "description": action_type.description, + "is_finish": bool(action_type.is_finish), + "is_message": bool(action_type.is_message), + "is_hidden": bool(action_type.is_hidden), + } + schema = action_type.arguments.model_json_schema() # type: ignore[attr-defined] + action_entry["arguments_schema"] = schema + actions_payload.append(action_entry) + + manifest = { + "run_id": self.paths.run_id, + "session_id": self.session_id, + "task": self.task, + "context": self.context, + "actions": actions_payload, + "task_id": self.task_id, + } + path = self.paths.session_manifest + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(manifest, f, ensure_ascii=False, indent=2) + + def get_cost(self) -> CostReport: + """Estimated session cost; default 0.0.""" + return CostReport.initialize_empty() + + @property + def task_id(self) -> str: + """Task identifier.""" + return "" + + @property + @abstractmethod + def task(self) -> str: + """Task description - benchmark defines what work to do.""" + pass + + @property + @abstractmethod + def context(self) -> Dict[str, Any]: + """Task context - benchmark provides necessary information.""" + pass + + @property + @abstractmethod + def actions(self) -> List[ActionType]: + """Available actions - benchmark defines action space.""" + pass + + @abstractmethod + def start(self) -> Optional[Observation]: + """Current observation - session maintains state.""" + pass + + @abstractmethod + def step(self, action: Action) -> Optional[Observation]: + """Execute action - session controls execution, returns None when done.""" + pass + + @abstractmethod + def done(self) -> bool: + """Check completion - session knows when task is finished.""" + pass + + @abstractmethod + def score(self) -> Dict[str, Any]: + """Evaluate performance - session/benchmark controls scoring. + + Should return at least two fields: + + "success" - True iff the session execution completed without an error + "score" - The score of the session execution, should be None if an error occurred. + """ + pass + + @abstractmethod + def close(self): + """Cleanup resources - session manages its own resources.""" + pass diff --git a/labs/AgentStream/exgentic/src/exgentic/core/types/__init__.py b/labs/AgentStream/exgentic/src/exgentic/core/types/__init__.py new file mode 100644 index 00000000..31836b13 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/core/types/__init__.py @@ -0,0 +1,75 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from .action import ( + Action, + ActionType, + FinishAction, + Message, + MessageAction, + ParallelAction, + SequentialAction, + SingleAction, + ValidationReport, +) +from .evaluation import BaseEvaluationConfig +from .model_settings import ModelSettings, RetryStrategy +from .observation import ( + EmptyObservation, + MessageObservation, + MessagePayload, + MultiObservation, + Observation, + SingleObservation, +) +from .run import ( + BenchmarkResults, + Integration, + RunConfig, + RunPlan, + RunResults, + RunStatus, +) +from .session import ( + SessionConfig, + SessionExecutionStatus, + SessionIndex, + SessionOutcomeStatus, + SessionResults, + SessionScore, + SessionStatus, +) + +__all__ = [ + "Action", + "ActionType", + "FinishAction", + "Message", + "MessageAction", + "ParallelAction", + "SequentialAction", + "SingleAction", + "ValidationReport", + "EmptyObservation", + "MessageObservation", + "MessagePayload", + "MultiObservation", + "Observation", + "SingleObservation", + "ModelSettings", + "RetryStrategy", + "BenchmarkResults", + "Integration", + "BaseEvaluationConfig", + "RunConfig", + "RunPlan", + "RunStatus", + "RunResults", + "SessionConfig", + "SessionExecutionStatus", + "SessionOutcomeStatus", + "SessionScore", + "SessionStatus", + "SessionResults", + "SessionIndex", +] diff --git a/labs/AgentStream/exgentic/src/exgentic/core/types/action.py b/labs/AgentStream/exgentic/src/exgentic/core/types/action.py new file mode 100644 index 00000000..f6b356dd --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/core/types/action.py @@ -0,0 +1,108 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import inspect +import uuid +from abc import ABC, abstractmethod +from typing import Any, Literal, Optional, get_type_hints + +from pydantic import BaseModel, Field + + +class Action(BaseModel, ABC): + @abstractmethod + def to_action_list(self): + pass + + pass + + +class ValidationReport(BaseModel): + valid: bool = True + name_valid: bool = True + args_valid: bool = True + error: Optional[str] = None + details: dict[str, Any] = Field(default_factory=dict) + + +class SingleAction(Action): + name: str + arguments: BaseModel + id: str = Field(default_factory=lambda: str(uuid.uuid4()), frozen=True) + validation: ValidationReport = Field(default_factory=ValidationReport) + + def to_action_list(self): + return [self] + + +class Message(BaseModel): + content: str + + +class MessageAction(SingleAction): + name: Literal["message"] = "message" + arguments: Message + + +class FinishAction(SingleAction): + name: str + arguments: BaseModel + + +class ParallelAction(Action): + actions: list[SingleAction] + + def to_action_list(self): + return self.actions + + +class SequentialAction(Action): + actions: list[SingleAction] + + def to_action_list(self): + return self.actions + + +class ActionType(BaseModel): + name: str + description: str + cls: type[SingleAction] + # Hints for agent/adapter handling (optional) + is_message: bool = False + is_finish: bool = False + is_hidden: bool = False + + @property + def arguments(self) -> type[BaseModel]: + """Return the resolved pydantic model type for the action's arguments. + + Handles forward-referenced annotations (e.g., from `from __future__ import annotations`). + Falls back to the raw annotation if resolution fails. + """ + module = inspect.getmodule(self.cls) + globalns = vars(module) if module else {} + try: + hints = get_type_hints(self.cls, globalns=globalns, localns=globalns) + arg_t = hints.get("arguments") + if arg_t is not None: + if isinstance(arg_t, str): + resolved = globalns.get(arg_t) + if isinstance(resolved, type): + return resolved # type: ignore[return-value] + return arg_t # type: ignore[return-value] + except Exception: + pass + arg_t = self.cls.__annotations__.get("arguments") + if isinstance(arg_t, str): + resolved = globalns.get(arg_t) + if isinstance(resolved, type): + return resolved # type: ignore[return-value] + return arg_t # type: ignore[return-value] + + def build_action(self, arguments: Any, *, action_id: Optional[str] = None) -> SingleAction: + """Convenience wrapper around core.actions.build_action for this action type.""" + from ..actions import build_action # Local import to avoid circular dependency + + return build_action(self, arguments, action_id=action_id) diff --git a/labs/AgentStream/exgentic/src/exgentic/core/types/evaluation.py b/labs/AgentStream/exgentic/src/exgentic/core/types/evaluation.py new file mode 100644 index 00000000..f7aeeb28 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/core/types/evaluation.py @@ -0,0 +1,151 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import hashlib +import json +from typing import Any, Optional + +from pydantic import BaseModel, StrictStr, model_validator + +from ...interfaces.registry import ( + apply_subset_kwargs, + load_agent, + load_benchmark, +) +from ..context import try_get_context +from .model_settings import ModelSettings + + +def _validate_kwargs(kind: str, cls: type, kwargs: dict[str, Any]) -> None: + if not isinstance(cls, type) or not issubclass(cls, BaseModel): + return + cls.model_validate(kwargs) + + +def _compute_run_id( + *, + benchmark: str, + agent: str, + benchmark_kwargs: dict[str, Any], + agent_kwargs: dict[str, Any], +) -> str: + payload = { + "benchmark": { + "slug_name": benchmark, + "params": benchmark_kwargs, + }, + "agent": { + "slug_name": agent, + "params": agent_kwargs, + }, + } + encoded = json.dumps( + payload, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + default=str, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest()[:12] + + +class BaseEvaluationConfig(BaseModel): + """Shared evaluation config with canonical normalization.""" + + benchmark: StrictStr + agent: StrictStr + subset: Optional[str] = None + output_dir: str = "./outputs" + cache_dir: Optional[str] = None + run_id: Optional[str] = None + model: Optional[str] = None + benchmark_kwargs: Optional[dict[str, Any]] = None + agent_kwargs: Optional[dict[str, Any]] = None + + @model_validator(mode="before") + @classmethod + def _normalize(cls, values): + if not isinstance(values, dict): + return values + payload = dict(values) + benchmark = payload.get("benchmark") + agent = payload.get("agent") + benchmark_kwargs = payload.get("benchmark_kwargs") or {} + agent_kwargs = payload.get("agent_kwargs") or {} + if not isinstance(benchmark_kwargs, dict): + raise TypeError("benchmark_kwargs must be a dict") + if not isinstance(agent_kwargs, dict): + raise TypeError("agent_kwargs must be a dict") + + subset = payload.get("subset") + if subset is not None and benchmark: + benchmark_kwargs = apply_subset_kwargs(str(benchmark), str(subset), dict(benchmark_kwargs)) + else: + benchmark_kwargs = dict(benchmark_kwargs) + + agent_kwargs = dict(agent_kwargs) + if "model_settings" in agent_kwargs: + model_cfg = agent_kwargs["model_settings"] + if model_cfg is None: + pass + elif isinstance(model_cfg, ModelSettings): + pass + elif isinstance(model_cfg, dict): + agent_kwargs["model_settings"] = ModelSettings(**model_cfg) + else: + raise ValueError("agent.model_settings must be a ModelSettings or dict.") + + model = payload.get("model") + if model is not None: + if "model" in agent_kwargs and agent_kwargs["model"] != model: + raise ValueError("Conflicting model selection: " f"model={agent_kwargs['model']} but model={model}") + agent_kwargs["model"] = model + + if benchmark: + bench_cls = load_benchmark(str(benchmark)) + _validate_kwargs("benchmark", bench_cls, benchmark_kwargs) + if agent: + agent_cls = load_agent(str(agent)) + _validate_kwargs("agent", agent_cls, agent_kwargs) + + payload["benchmark_kwargs"] = benchmark_kwargs + payload["agent_kwargs"] = agent_kwargs + if payload.get("run_id") is None and benchmark and agent: + ctx = try_get_context() + payload["run_id"] = (ctx.run_id if ctx else None) or _compute_run_id( + benchmark=str(benchmark), + agent=str(agent), + benchmark_kwargs=benchmark_kwargs, + agent_kwargs=agent_kwargs, + ) + return payload + + def canonical_payload(self) -> dict[str, Any]: + return self.model_dump(mode="json", exclude_none=False) + + def fingerprint(self) -> str: + encoded = json.dumps( + self.canonical_payload(), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + default=str, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + def get_context(self): + from ..context import run_scope + + return run_scope( + output_dir=self.output_dir, + cache_dir=self.cache_dir, + run_id=self.run_id, + ) + + @classmethod + def from_file(cls, path: str): + with open(path, encoding="utf-8") as f: + payload = json.load(f) + return cls.model_validate(payload) diff --git a/labs/AgentStream/exgentic/src/exgentic/core/types/model_settings.py b/labs/AgentStream/exgentic/src/exgentic/core/types/model_settings.py new file mode 100644 index 00000000..a39b93c3 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/core/types/model_settings.py @@ -0,0 +1,62 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from enum import Enum + +from pydantic import BaseModel, field_validator + + +class RetryStrategy(str, Enum): + EXPONENTIAL_BACKOFF = "exponential_backoff_retry" + CONSTANT = "constant_retry" + + +class ModelSettings(BaseModel): + temperature: float | None = 1.0 + top_p: float | None = None + max_tokens: int | None = None + reasoning_effort: str | None = None + num_retries: int | None = 5 + retry_after: float = 0.5 + retry_strategy: RetryStrategy = RetryStrategy.EXPONENTIAL_BACKOFF + + @field_validator("temperature") + @classmethod + def _validate_temperature(cls, value: float | None) -> float | None: + if value is not None and value < 0: + raise ValueError("temperature must be >= 0") + return value + + @field_validator("max_tokens") + @classmethod + def _validate_max_tokens(cls, value: int | None) -> int | None: + if value is not None and value < 0: + raise ValueError("max_tokens must be >= 0") + return value + + @field_validator("top_p") + @classmethod + def _validate_top_p(cls, value: float | None) -> float | None: + if value is None: + return value + if value < 0 or value > 1: + raise ValueError("top_p must be between 0 and 1") + return value + + @field_validator("num_retries") + @classmethod + def _validate_num_retries(cls, value: int | None) -> int | None: + if value is None: + return None + if value < 0: + raise ValueError("num_retries must be >= 0") + return value + + @field_validator("retry_after") + @classmethod + def _validate_retry_after(cls, value: float) -> float: + if value < 0: + raise ValueError("retry_after must be >= 0") + return value diff --git a/labs/AgentStream/exgentic/src/exgentic/core/types/observation.py b/labs/AgentStream/exgentic/src/exgentic/core/types/observation.py new file mode 100644 index 00000000..74c982bb --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/core/types/observation.py @@ -0,0 +1,175 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + +from pydantic import BaseModel, Field + +from .action import SingleAction + + +class Observation(BaseModel, ABC): + """Base class to encapsulate both single and multiple observations.""" + + @abstractmethod + def to_observation_list(self): + pass + + @abstractmethod + def is_empty(self) -> bool: + """Returns True if the observation carries no meaningful content.""" + raise NotImplementedError + + +class SingleObservation(Observation): + """Observation that includes a single arbitrary result and a list of actions that invoked it. + + (i.e. the actions that generated it the observation) + + To allow the observation to be used in place of the results in CodeAgents, + we map all the magic methods of the observation to the result object. + """ + + result: Any + invoking_actions: list[SingleAction] = Field(repr=False, default_factory=list) + + def to_observation_list(self): + return [self] + + def is_empty(self) -> bool: + if self.result is None: + return True + if isinstance(self.result, str) and self.result.strip() == "": + return True + if isinstance(self.result, (list, tuple, set, dict)) and len(self.result) == 0: + return True + return False + + def __str__(self, *args, **kwargs): + return self.result.__str__(*args, **kwargs) + + def __repr__(self, *args, **kwargs): + return self.result.__repr__(*args, **kwargs) + + def __len__(self, *args, **kwargs): + return self.result.__len__(*args, **kwargs) + + def __getitem__(self, *args, **kwargs): + return self.result.__getitem__(*args, **kwargs) + + def __setitem__(self, *args, **kwargs): + return self.result.__setitem__(*args, **kwargs) + + def __delitem__(self, *args, **kwargs): + return self.result.__delitem__(*args, **kwargs) + + def __iter__(self, *args, **kwargs): + return self.result.__iter__(*args, **kwargs) + + def __contains__(self, *args, **kwargs): + return self.result.__contains__(*args, **kwargs) + + def __eq__(self, *args, **kwargs): + return self.result.__eq__(*args, **kwargs) + + def __ne__(self, *args, **kwargs): + return self.result.__ne__(*args, **kwargs) + + def __lt__(self, *args, **kwargs): + return self.result.__lt__(*args, **kwargs) + + def __le__(self, *args, **kwargs): + return self.result.__le__(*args, **kwargs) + + def __gt__(self, *args, **kwargs): + return self.result.__gt__(*args, **kwargs) + + def __ge__(self, *args, **kwargs): + return self.result.__ge__(*args, **kwargs) + + def __add__(self, *args, **kwargs): + return self.result.__add__(*args, **kwargs) + + def __sub__(self, *args, **kwargs): + return self.result.__sub__(*args, **kwargs) + + def __mul__(self, *args, **kwargs): + return self.result.__mul__(*args, **kwargs) + + def __truediv__(self, *args, **kwargs): + return self.result.__truediv__(*args, **kwargs) + + def __floordiv__(self, *args, **kwargs): + return self.result.__floordiv__(*args, **kwargs) + + def __mod__(self, *args, **kwargs): + return self.result.__mod__(*args, **kwargs) + + def __pow__(self, *args, **kwargs): + return self.result.__pow__(*args, **kwargs) + + def __and__(self, *args, **kwargs): + return self.result.__and__(*args, **kwargs) + + def __or__(self, *args, **kwargs): + return self.result.__or__(*args, **kwargs) + + def __xor__(self, *args, **kwargs): + return self.result.__xor__(*args, **kwargs) + + def __lshift__(self, *args, **kwargs): + return self.result.__lshift__(*args, **kwargs) + + def __rshift__(self, *args, **kwargs): + return self.result.__rshift__(*args, **kwargs) + + def __neg__(self, *args, **kwargs): + return self.result.__neg__(*args, **kwargs) + + def __pos__(self, *args, **kwargs): + return self.result.__pos__(*args, **kwargs) + + def __abs__(self, *args, **kwargs): + return self.result.__abs__(*args, **kwargs) + + def __invert__(self, *args, **kwargs): + return self.result.__invert__(*args, **kwargs) + + def __call__(self, *args, **kwargs): + return self.result.__call__(*args, **kwargs) + + +class MessagePayload(BaseModel): + sender: str + message: str + + +class MessageObservation(SingleObservation): + """Observation carrying a structured message payload (sender + message).""" + + result: MessagePayload + + +class EmptyObservation(SingleObservation): + """Explicit empty observation to signal 'no initial content'.""" + + result: Any = None + invoking_actions: list[SingleAction] = Field(repr=False, default_factory=list) + + def is_empty(self) -> bool: + return True + + +class MultiObservation(Observation): + """An observation which is actually a collection of multiple observations.""" + + observations: list[SingleObservation] + + def to_observation_list(self): + return self.observations + + def is_empty(self) -> bool: + return all(obs.is_empty() for obs in self.observations) diff --git a/labs/AgentStream/exgentic/src/exgentic/core/types/run.py b/labs/AgentStream/exgentic/src/exgentic/core/types/run.py new file mode 100644 index 00000000..5062782a --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/core/types/run.py @@ -0,0 +1,304 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import random +from typing import Any, Literal, Optional + +from pydantic import BaseModel, Field, field_validator + +from .evaluation import BaseEvaluationConfig +from .session import ( + SessionConfig, + SessionExecutionStatus, + SessionResults, + SessionStatus, +) + + +class BenchmarkResults(BaseModel): + """Minimal benchmark-level results returned by Benchmark.aggregate_sessions().""" + + benchmark_name: str + total_tasks: int + score: float + metrics: dict[str, Any] = {} + + +class RunResults(BaseModel): + """Aggregated run results produced by the tracker.""" + + benchmark_name: str + benchmark_slug_name: str | None = None + agent_name: str + agent_slug_name: str | None = None + model_name: str | None = None + model_names: list[str] | None = None + subset_name: str | None = None + total_sessions: int + planned_sessions: Optional[int] = None + planned_session_ids: Optional[list[str]] = None + executed_session_ids: list[str] = Field(default_factory=list) + max_workers: Optional[int] = None + successful_sessions: int + # Primary benchmark-level outcome (from evaluator.aggregate_sessions()) + benchmark_score: Optional[float] = None + benchmark_results: Optional[dict[str, Any]] = None + average_score: Optional[float] = None + average_agent_cost: Optional[float] = None + total_agent_cost: Optional[float] = None + average_benchmark_cost: Optional[float] = None + total_benchmark_cost: Optional[float] = None + total_run_cost: Optional[float] = None + accumulated_agent_report: Optional[Any] = None + accumulated_benchmark_report: Optional[Any] = None + session_results: list[SessionResults] + average_steps: Optional[float] = None + average_action_count: Optional[float] = None + average_invalid_action_count: Optional[float] = None + average_invalid_action_percent: Optional[float] = None + percent_finished: Optional[float] = None + percent_successful: Optional[float] = None + percent_finished_successful: Optional[float] = None + percent_finished_unsuccessful: Optional[float] = None + percent_unfinished: Optional[float] = None + percent_error: Optional[float] = None + # Aggregation provenance + aggregation_mode: Optional[str] = None + completed_sessions: Optional[int] = None + incomplete_sessions: Optional[int] = None + missing_sessions: Optional[int] = None + running_sessions: Optional[int] = None + aggregated_session_ids: Optional[list[str]] = None + skipped_session_ids: Optional[list[str]] = None + skipped_session_reasons: Optional[dict[str, str]] = None + missing_result_files: Optional[list[str]] = None + exgentic_version: str | None = None + + +class Integration(BaseModel): + """Metadata about a benchmark or agent integration.""" + + name: str + type: Literal["benchmark", "agent"] + version: str + bundled: bool + installed: bool + entry_point: str + + +class RunStatus(BaseModel): + """Snapshot of the current run status and existing session artifacts.""" + + run_id: str + output_dir: str + run_root: str + run_dir: str + sessions_root: str + results_path: str + results_exists: bool + benchmark_results_path: str + benchmark_results_exists: bool + benchmark_name: str + benchmark_slug_name: str + agent_name: str + agent_slug_name: str + model_name: Optional[str] = None + subset_name: Optional[str] = None + task_ids: list[str] + total_tasks: int + session_statuses: list[SessionStatus] = Field(default_factory=list) + completed_sessions: int = 0 + running_sessions: int = 0 + incomplete_sessions: int = 0 + missing_sessions: int = 0 + + @classmethod + def from_config(cls, run_config: RunConfig) -> RunStatus: + session_configs = run_config.get_sessions() + return cls.from_session_configs(run_config, session_configs) + + @classmethod + def from_session_configs( + cls, + run_config: RunConfig, + session_configs: list[SessionConfig], + ) -> RunStatus: + from ...utils.paths import get_run_paths + + context_config = run_config + if session_configs: + first = session_configs[0] + updates = {} + if run_config.run_id is None and first.run_id: + updates["run_id"] = first.run_id + if run_config.cache_dir is None and first.cache_dir: + updates["cache_dir"] = first.cache_dir + if run_config.output_dir is None and first.output_dir: + updates["output_dir"] = first.output_dir + if updates: + context_config = run_config.model_copy(update=updates) + with context_config.get_context(): + run_paths = get_run_paths() + statuses = [ + SessionStatus.from_config( + session_config, + run_paths=run_paths, + ) + for session_config in session_configs + ] + task_ids = [str(item.task_id) for item in session_configs] + completed = sum(1 for item in statuses if item.status == SessionExecutionStatus.COMPLETED) + running = sum(1 for item in statuses if item.status == SessionExecutionStatus.RUNNING) + incomplete = sum(1 for item in statuses if item.status == SessionExecutionStatus.INCOMPLETE) + missing = sum(1 for item in statuses if item.status == SessionExecutionStatus.MISSING) + + return cls( + run_id=run_paths.run_id, + output_dir=str(run_paths.root.parent), + run_root=str(run_paths.root), + run_dir=str(run_paths.run_dir), + sessions_root=str(run_paths.sessions_root), + results_path=str(run_paths.results), + results_exists=run_paths.results.exists(), + benchmark_results_path=str(run_paths.benchmark_results), + benchmark_results_exists=run_paths.benchmark_results.exists(), + benchmark_name=run_config.benchmark, + benchmark_slug_name=run_config.benchmark, + agent_name=run_config.agent, + agent_slug_name=run_config.agent, + model_name=(run_config.model or (run_config.agent_kwargs or {}).get("model")), + subset_name=run_config.subset, + task_ids=task_ids, + total_tasks=len(task_ids), + session_statuses=statuses, + completed_sessions=completed, + running_sessions=running, + incomplete_sessions=incomplete, + missing_sessions=missing, + ) + + +class RunPlan(BaseModel): + """Planned execution derived from RunStatus.""" + + run_config: RunConfig + overwrite_sessions: bool + to_run: list[SessionConfig] = Field(default_factory=list) + reuse: list[SessionConfig] = Field(default_factory=list) + running: list[SessionConfig] = Field(default_factory=list) + missing: list[SessionConfig] = Field(default_factory=list) + incomplete: list[SessionConfig] = Field(default_factory=list) + + @classmethod + def from_config_and_status( + cls, + run_config: RunConfig, + status: RunStatus, + ) -> RunPlan: + overwrite_sessions = run_config.overwrite_sessions + to_run: list[SessionConfig] = [] + reuse: list[SessionConfig] = [] + running: list[SessionConfig] = [] + missing: list[SessionConfig] = [] + incomplete: list[SessionConfig] = [] + + for session_status in status.session_statuses: + session_config = run_config.to_session_config(session_status.task_id) + match session_status.status: + case SessionExecutionStatus.RUNNING: + running.append(session_config) + continue + case SessionExecutionStatus.MISSING: + missing.append(session_config) + case SessionExecutionStatus.INCOMPLETE: + incomplete.append(session_config) + case SessionExecutionStatus.COMPLETED: + if overwrite_sessions: + to_run.append(session_config) + else: + reuse.append(session_config) + continue + to_run.append(session_config) + + return cls( + run_config=run_config, + overwrite_sessions=overwrite_sessions, + to_run=to_run, + reuse=reuse, + running=running, + missing=missing, + incomplete=incomplete, + ) + + +class RunConfig(BaseEvaluationConfig): + """Configuration for a run of multiple sessions.""" + + task_ids: Optional[list[str]] = None + num_tasks: Optional[int] = None + max_workers: Optional[int] = None + max_steps: int = 100 + max_actions: int = 100 + overwrite_sessions: bool = False + + @field_validator("max_steps", "max_actions") + @classmethod + def _validate_limits(cls, value: int) -> int: + if value <= 0: + raise ValueError("limit must be > 0") + return value + + def to_session_config(self, task_id: str) -> SessionConfig: + """Derive a SessionConfig for a single task from this run config.""" + return SessionConfig.model_construct( + benchmark=self.benchmark, + agent=self.agent, + task_id=str(task_id), + subset=self.subset, + output_dir=self.output_dir, + cache_dir=self.cache_dir, + run_id=self.run_id, + model=self.model, + overwrite_sessions=self.overwrite_sessions, + benchmark_kwargs=dict(self.benchmark_kwargs or {}), + agent_kwargs=dict(self.agent_kwargs or {}), + ) + + def get_session_configs( + self, + *, + resolved_config: RunConfig | None = None, + ) -> list[SessionConfig]: + from ...interfaces.registry import load_benchmark + + resolved = resolved_config or self + task_ids = resolved.task_ids + + if task_ids is None: + bench_cls = load_benchmark(resolved.benchmark) + benchmark = bench_cls(**(resolved.benchmark_kwargs or {})) + evaluator = benchmark.get_evaluator() + try: + selected = [str(t) for t in evaluator.list_tasks()] + if resolved.num_tasks is not None: + seed = benchmark.seed + rng = random.Random(seed if seed is not None else 0) + rng.shuffle(selected) + selected = selected[: int(resolved.num_tasks)] + finally: + try: + evaluator.close() + except Exception: + pass + benchmark.close() + else: + selected = [str(t) for t in task_ids] + if resolved.num_tasks is not None: + selected = selected[: int(resolved.num_tasks)] + + return [resolved.to_session_config(task_id) for task_id in selected] + + def get_sessions(self) -> list[SessionConfig]: + return self.get_session_configs() diff --git a/labs/AgentStream/exgentic/src/exgentic/core/types/session.py b/labs/AgentStream/exgentic/src/exgentic/core/types/session.py new file mode 100644 index 00000000..d9e58987 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/core/types/session.py @@ -0,0 +1,223 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import hashlib +import json +from contextlib import contextmanager +from enum import StrEnum +from typing import Any, Optional + +from filelock import FileLock, Timeout +from pydantic import BaseModel, Field, field_validator + +from ...utils.cost import CostReport +from .evaluation import BaseEvaluationConfig + + +class SessionOutcomeStatus(StrEnum): + SUCCESS = "success" + UNSUCCESSFUL = "unsuccessful" + UNFINISHED = "unfinished" + LIMIT_REACHED = "limit_reached" + ERROR = "error" + CANCELLED = "cancelled" + UNKNOWN = "unknown" + + +class SessionExecutionStatus(StrEnum): + MISSING = "missing" + INCOMPLETE = "incomplete" + COMPLETED = "completed" + RUNNING = "running" + + +class SessionResults(BaseModel): + """Results of a single session execution.""" + + session_id: str + success: bool + score: Optional[float] = None + is_finished: Optional[bool] = None + status: SessionOutcomeStatus = SessionOutcomeStatus.UNKNOWN + steps: int + action_count: int = 0 + invalid_action_count: int = 0 + agent_cost: float + benchmark_cost: float + execution_time: float + details: dict[str, Any] = {} + cost_reports: dict[str, CostReport] = Field(default_factory=dict) + task_id: Optional[str] = None + + @field_validator("cost_reports", mode="before") + @classmethod + def accept_instances(cls, v): + if not isinstance(v, dict): + raise TypeError(f"cost_reports must be a dict[str, CostReport | dict], got {type(v)}") + + for key, val in v.items(): + if isinstance(val, CostReport) or isinstance(val, dict): + continue + + raise TypeError(f"Invalid type for cost_reports[{key}]: {type(val)}") + + return v + + +class SessionScore(BaseModel): + """Minimal per-session score returned by Session.score().""" + + score: float + success: bool + is_finished: Optional[bool] = None + session_metrics: dict[str, Any] = {} + session_metadata: dict[str, Any] = {} + + +class SessionStatus(BaseModel): + """Filesystem status for a single task session.""" + + task_id: str + session_id: str + results_path: str + session_dir: str + status: SessionExecutionStatus + result_status: Optional[SessionOutcomeStatus] = None + + @classmethod + def _is_session_locked(cls, session_paths) -> bool: + if not session_paths.lock.exists(): + return False + lock = FileLock(str(session_paths.lock)) + try: + lock.acquire(timeout=0) + except Timeout: + return True + lock.release() + return False + + @classmethod + def _extract_result_status(cls, results_path) -> Optional[SessionOutcomeStatus]: + try: + payload = json.loads(results_path.read_text(encoding="utf-8")) + except Exception: + return None + status = payload.get("status") + if status: + try: + return SessionOutcomeStatus(str(status)) + except ValueError: + return None + success = payload.get("success") + is_finished = payload.get("is_finished") + details = payload.get("details") or {} + metadata = details.get("session_metadata") or {} + error_source = metadata.get("error_source") + if error_source == "cancelled": + return SessionOutcomeStatus.CANCELLED + if metadata.get("error") or error_source in ("agent", "benchmark"): + return SessionOutcomeStatus.ERROR + if is_finished is True: + return SessionOutcomeStatus.SUCCESS if success else SessionOutcomeStatus.UNSUCCESSFUL + if is_finished is False: + return SessionOutcomeStatus.UNFINISHED + return None + + @classmethod + def from_config(cls, session_config, *, run_paths=None) -> SessionStatus: + from ...core.context import get_context + from ...utils.paths import RunPaths + + session_id = session_config.get_session_id() + if run_paths is None: + ctx = get_context() + if session_config.run_id: + run_paths = RunPaths(run_id=session_config.run_id, output_dir=ctx.output_dir) + else: + run_paths = RunPaths.from_context(ctx) + sess_paths = run_paths.session(session_id) + results_exists = sess_paths.results.exists() + session_dir_exists = sess_paths.root.exists() + is_locked = cls._is_session_locked(sess_paths) + result_status: Optional[SessionOutcomeStatus] = None + if is_locked: + status = SessionExecutionStatus.RUNNING + elif results_exists: + result_status = cls._extract_result_status(sess_paths.results) + if result_status in ( + SessionOutcomeStatus.ERROR, + SessionOutcomeStatus.CANCELLED, + ): + status = SessionExecutionStatus.INCOMPLETE + else: + status = SessionExecutionStatus.COMPLETED + elif session_dir_exists: + status = SessionExecutionStatus.INCOMPLETE + else: + status = SessionExecutionStatus.MISSING + return cls( + task_id=str(session_config.task_id), + session_id=session_id, + results_path=str(sess_paths.results), + session_dir=str(sess_paths.root), + status=status, + result_status=result_status, + ) + + +class SessionIndex(BaseModel): + """Minimal mapping between a task id and a session id.""" + + task_id: str + session_id: str + + +class SessionConfig(BaseEvaluationConfig): + """Configuration for running a single session.""" + + task_id: str + overwrite_sessions: bool = False + + def session_id_payload(self) -> dict[str, Any]: + return { + "benchmark": self.benchmark, + "benchmark_kwargs": dict(self.benchmark_kwargs or {}), + "agent": self.agent, + "agent_kwargs": dict(self.agent_kwargs or {}), + "subset": self.subset, + "task_id": str(self.task_id), + "model": self.model, + } + + def get_session_id(self) -> str: + encoded = json.dumps( + self.session_id_payload(), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + default=str, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest()[:8] + + def to_index(self) -> SessionIndex: + return SessionIndex( + task_id=str(self.task_id), + session_id=self.get_session_id(), + ) + + def get_context(self): + from ..context import run_scope, session_scope + + @contextmanager + def _ctx(): + with run_scope( + output_dir=self.output_dir, + cache_dir=self.cache_dir, + run_id=self.run_id, + ): + with session_scope(self.get_session_id(), task_id=str(self.task_id)): + yield + + return _ctx() diff --git a/labs/AgentStream/exgentic/src/exgentic/environment/__init__.py b/labs/AgentStream/exgentic/src/exgentic/environment/__init__.py new file mode 100644 index 00000000..d28851de --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/environment/__init__.py @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from .manager import EnvironmentManager, EnvType + +__all__ = ["EnvironmentManager", "EnvType"] diff --git a/labs/AgentStream/exgentic/src/exgentic/environment/docker.py b/labs/AgentStream/exgentic/src/exgentic/environment/docker.py new file mode 100644 index 00000000..c19364eb --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/environment/docker.py @@ -0,0 +1,405 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Docker environment: builds a Docker image with dependencies baked in.""" + +from __future__ import annotations + +import hashlib +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +import tomllib + +from .helpers import find_package_file, read_lines + +_DOCKER_CLI_VERSION = "27.5.1" +_DOCKER_CLI_RUN = ( + "RUN apt-get update && apt-get install -y --no-install-recommends curl" + " && rm -rf /var/lib/apt/lists/*" + f" && ARCH=$(uname -m)" + f" && curl -fsSL https://download.docker.com/linux/static/stable/${{ARCH}}/docker-{_DOCKER_CLI_VERSION}.tgz" + " | tar xz --strip-components=1 -C /usr/local/bin docker/docker" +) + + +class DockerBackend: + """Backend that builds a Docker image with dependencies baked in. + + When *project_root* is provided the build is split into two images: + + 1. **Base image** (``exgentic-base:{hash}``) — Python + git + uv + the + project installed via a two-layer cache-friendly build. This image is + shared across all environments that use the same project root and is + only rebuilt when the project's ``pyproject.toml`` or + ``_IMAGE_VERSION`` changes. + + 2. **Bench image** (``{name}:{hash}``) — ``FROM exgentic-base`` with + benchmark-specific system deps, requirements, extra packages, an + optional Docker CLI (for sibling-container benchmarks), and the + setup script. + + When *project_root* is ``None`` the legacy single-image build is used. + """ + + _IMAGE_VERSION = "v1" + + def install( + self, + env_dir: Path, + *, + module_path: str | None = None, + **kwargs: object, + ) -> dict: + """Build a Docker image for the environment. + + Args: + env_dir: Root directory for this environment. + module_path: Dotted module path for locating package resources. + **kwargs: Accepts ``name``, ``force``, ``project_root`` (Path), + ``packages`` (list[str]), and ``docker_socket`` (bool). + + Returns: + Marker data with ``image`` tag. + """ + name: str = kwargs.get("name", env_dir.name) # type: ignore[assignment] + force: bool = kwargs.get("force", False) # type: ignore[assignment] + project_root: Path | None = kwargs.get("project_root") # type: ignore[assignment] + packages: list[str] | None = kwargs.get("packages") # type: ignore[assignment] + docker_socket: bool = bool(kwargs.get("docker_socket", False)) + + req_path = find_package_file(module_path, "requirements.txt") if module_path else None + setup_path = find_package_file(module_path, "setup.sh") if module_path else None + sysdeps_path = find_package_file(module_path, "system-deps.txt") if module_path else None + + if project_root is not None: + return self._install_with_base( + name, + project_root, + req_path=req_path, + setup_path=setup_path, + sysdeps_path=sysdeps_path, + packages=packages, + docker_socket=docker_socket, + force=force, + ) + + image_tag = self._image_tag( + name, + req_path, + setup_path, + sysdeps_path, + packages=packages, + docker_socket=docker_socket, + ) + if not force and self._image_exists(image_tag): + return {"image": image_tag} + self._build_image( + image_tag, + req_path, + setup_path, + sysdeps_path, + packages=packages, + docker_socket=docker_socket, + ) + return {"image": image_tag} + + def uninstall(self, env_dir: Path, marker_data: dict) -> None: + """Remove the Docker images referenced in the marker data. + + The bench image is always removed. The base image (if present) is + attempted too — ``docker rmi`` will silently fail if another bench + image still depends on it, so the last environment using a base tag + will clean it up automatically. + """ + for key in ("image", "base_image"): + tag = marker_data.get(key) + if tag: + subprocess.run( + ["docker", "rmi", tag], + check=False, + capture_output=True, + text=True, + ) + + # ------------------------------------------------------------------ + # Two-image path (project_root provided) + # ------------------------------------------------------------------ + + def _install_with_base( + self, + name: str, + project_root: Path, + *, + req_path: Path | None, + setup_path: Path | None, + sysdeps_path: Path | None, + packages: list[str] | None, + docker_socket: bool, + force: bool, + ) -> dict: + base_tag = self._base_image_tag(project_root) + bench_tag = self._bench_image_tag( + name, + base_tag, + req_path, + setup_path, + sysdeps_path, + packages=packages, + docker_socket=docker_socket, + ) + + if not force and self._image_exists(bench_tag): + return {"image": bench_tag} + + if force or not self._image_exists(base_tag): + self._build_base_image(base_tag, project_root) + + self._build_bench_image( + bench_tag, + base_tag, + req_path=req_path, + setup_path=setup_path, + sysdeps_path=sysdeps_path, + packages=packages, + docker_socket=docker_socket, + ) + return {"image": bench_tag, "base_image": base_tag} + + @classmethod + def _base_image_tag(cls, project_root: Path) -> str: + """Deterministic tag for the shared base image.""" + h = hashlib.sha256() + h.update(cls._IMAGE_VERSION.encode()) + h.update(b"\x00") + pyproject = project_root / "pyproject.toml" + if pyproject.is_file(): + h.update(pyproject.read_text().encode()) + h.update(b"\x00") + return f"exgentic-base:{h.hexdigest()[:12]}" + + @staticmethod + def _bench_image_tag( + name: str, + base_tag: str, + *file_paths: Path | None, + packages: list[str] | None = None, + docker_socket: bool = False, + ) -> str: + """Deterministic tag for the benchmark-specific image.""" + h = hashlib.sha256() + h.update(base_tag.encode()) + h.update(b"\x00") + for path in file_paths: + if path is not None: + h.update(path.read_text().encode()) + h.update(b"\x00") + if packages: + h.update("\n".join(sorted(packages)).encode()) + h.update(b"\x00") + if docker_socket: + h.update(b"docker-socket\x00") + safe_name = name.replace("/", "-") + return f"{safe_name}:{h.hexdigest()[:12]}" + + @staticmethod + def _build_base_image(tag: str, project_root: Path) -> None: + """Build the shared base image: Python + git + uv + project installed.""" + py_version = f"{sys.version_info.major}.{sys.version_info.minor}" + + lines = [ + f"FROM python:{py_version}-slim", + "RUN apt-get update && apt-get install -y --no-install-recommends git git-lfs" + " && rm -rf /var/lib/apt/lists/* && git lfs install", + "RUN pip install --no-cache-dir uv", + "ENV UV_SYSTEM_PYTHON=true", + "WORKDIR /app", + ] + + # Layer 1 — install dependencies (cached unless pyproject.toml changes). + copy_parts = ["COPY pyproject.toml ./"] + if (project_root / "README.md").is_file(): + copy_parts.append("COPY README.md ./") + lines.extend(copy_parts) + + pyproject_data = tomllib.loads((project_root / "pyproject.toml").read_text()) + pkg_name = pyproject_data.get("project", {}).get("name", "").replace("-", "_") + if pkg_name: + lines.append(f"RUN mkdir -p src/{pkg_name} && touch src/{pkg_name}/__init__.py") + + force_includes = ( + pyproject_data.get("tool", {}) + .get("hatch", {}) + .get("build", {}) + .get("targets", {}) + .get("wheel", {}) + .get("force-include", {}) + ) + for src_path in force_includes: + lines.append(f"RUN mkdir -p '{src_path}'") + + lines.append("RUN uv pip install --no-cache .") + + # Layer 2 — install source code only (fast, deps already cached). + lines.extend( + [ + "COPY src/ src/", + "RUN uv pip install --no-cache --no-deps .", + ] + ) + + with tempfile.NamedTemporaryFile( + mode="w", + prefix="exgentic-base-", + suffix=".Dockerfile", + delete=False, + ) as fh: + fh.write("\n".join(lines) + "\n") + dockerfile = Path(fh.name) + try: + subprocess.run( + ["docker", "build", "-f", str(dockerfile), "-t", tag, str(project_root)], + check=True, + ) + finally: + dockerfile.unlink(missing_ok=True) + + @staticmethod + def _build_bench_image( + tag: str, + base_tag: str, + *, + req_path: Path | None = None, + setup_path: Path | None = None, + sysdeps_path: Path | None = None, + packages: list[str] | None = None, + docker_socket: bool = False, + ) -> None: + """Build the benchmark-specific image on top of the base image.""" + tmp_dir = Path(tempfile.mkdtemp(prefix="exgentic-bench-")) + try: + lines = [f"FROM {base_tag}"] + + if sysdeps_path is not None: + pkgs = read_lines(sysdeps_path) + if pkgs: + lines.append( + "RUN apt-get update && apt-get install -y " + " ".join(pkgs) + " && rm -rf /var/lib/apt/lists/*" + ) + + if req_path is not None: + shutil.copy2(req_path, tmp_dir / "requirements.txt") + lines.append("COPY requirements.txt /tmp/") + lines.append("RUN GIT_LFS_SKIP_SMUDGE=1 uv pip install --no-cache -r /tmp/requirements.txt") + + if packages: + lines.append(f"RUN uv pip install --no-cache {' '.join(packages)}") + + if docker_socket: + lines.append(_DOCKER_CLI_RUN) + + if setup_path is not None: + shutil.copy2(setup_path, tmp_dir / "setup.sh") + lines.append("COPY setup.sh /tmp/") + lines.append("RUN EXGENTIC_DOCKER_BUILD=1 bash /tmp/setup.sh") + + (tmp_dir / "Dockerfile").write_text("\n".join(lines) + "\n") + subprocess.run( + ["docker", "build", "-t", tag, str(tmp_dir)], + check=True, + ) + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + + # ------------------------------------------------------------------ + # Single-image path (no project_root) + # ------------------------------------------------------------------ + + @staticmethod + def _image_tag( + name: str, + *file_paths: Path | None, + packages: list[str] | None = None, + docker_socket: bool = False, + ) -> str: + """Compute a deterministic image tag from content hashes.""" + h = hashlib.sha256() + for path in file_paths: + if path is not None: + h.update(path.read_text().encode()) + h.update(b"\x00") + if packages: + h.update("\n".join(sorted(packages)).encode()) + h.update(b"\x00") + if docker_socket: + h.update(b"docker-socket\x00") + safe_name = name.replace("/", "-") + return f"{safe_name}:{h.hexdigest()[:12]}" + + @staticmethod + def _image_exists(tag: str) -> bool: + result = subprocess.run( + ["docker", "image", "inspect", tag], + check=False, + capture_output=True, + text=True, + ) + return result.returncode == 0 + + @staticmethod + def _build_image( + tag: str, + req_path: Path | None, + setup_path: Path | None, + sysdeps_path: Path | None, + *, + packages: list[str] | None = None, + docker_socket: bool = False, + ) -> None: + """Build a single image without a project root.""" + py_version = f"{sys.version_info.major}.{sys.version_info.minor}" + tmp_dir = Path(tempfile.mkdtemp(prefix="exgentic-docker-")) + try: + lines = [f"FROM python:{py_version}-slim"] + + if sysdeps_path is not None: + pkgs = read_lines(sysdeps_path) + if pkgs: + lines.append( + "RUN apt-get update && apt-get install -y " + " ".join(pkgs) + " && rm -rf /var/lib/apt/lists/*" + ) + + lines.extend( + [ + "RUN pip install --no-cache-dir uv", + "ENV UV_SYSTEM_PYTHON=true", + ] + ) + + if req_path is not None: + shutil.copy2(req_path, tmp_dir / "requirements.txt") + lines.append("COPY requirements.txt /tmp/") + lines.append("RUN GIT_LFS_SKIP_SMUDGE=1 uv pip install --no-cache -r /tmp/requirements.txt") + + if packages: + lines.append(f"RUN uv pip install --no-cache {' '.join(packages)}") + + if docker_socket: + lines.append(_DOCKER_CLI_RUN) + + if setup_path is not None: + shutil.copy2(setup_path, tmp_dir / "setup.sh") + lines.append("COPY setup.sh /tmp/") + lines.append("RUN EXGENTIC_DOCKER_BUILD=1 bash /tmp/setup.sh") + + (tmp_dir / "Dockerfile").write_text("\n".join(lines) + "\n") + + subprocess.run( + ["docker", "build", "-t", tag, str(tmp_dir)], + check=True, + ) + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) diff --git a/labs/AgentStream/exgentic/src/exgentic/environment/helpers.py b/labs/AgentStream/exgentic/src/exgentic/environment/helpers.py new file mode 100644 index 00000000..9c1f2f71 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/environment/helpers.py @@ -0,0 +1,200 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Shared helpers for environment management.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from importlib import resources +from pathlib import Path + + +def get_exgentic_install_target() -> tuple[Path | None, list[str] | None]: + """Return ``(project_root, packages)`` for installing exgentic itself. + + If running from a source checkout, returns ``(project_root, None)`` + so backends install from source. Otherwise returns + ``(None, ["exgentic==X.Y.Z"])`` matching the running version. + + When installed from a local path (e.g. ``uv pip install /path/to/repo``), + uses PEP 610 ``direct_url.json`` to locate the original source tree. + Falls back to an unpinned ``exgentic`` spec for dev versions that + don't exist on PyPI. + """ + from ..adapters.runners._utils import find_project_root + + root = find_project_root() + if (root / "pyproject.toml").exists() and (root / "src" / "exgentic").is_dir(): + return root, None + + # Try PEP 610 direct_url.json — when installed from a local path, + # pip/uv record the source URL so we can find the original source tree. + try: + import json as _json + from importlib.metadata import distribution + + direct_url_text = distribution("exgentic").read_text("direct_url.json") + if direct_url_text: + url = _json.loads(direct_url_text).get("url", "") + if url.startswith("file://"): + source_path = Path(url.removeprefix("file://")) + if (source_path / "pyproject.toml").exists() and (source_path / "src" / "exgentic").is_dir(): + return source_path, None + except Exception: + pass + + from importlib.metadata import version + + ver = version("exgentic") + # Dev versions (e.g. 0.3.3.dev32+ga685f27) don't exist on PyPI. + # Fall back to unpinned install so the latest release is used. + if ".dev" in ver or "+" in ver: + return None, ["exgentic"] + return None, [f"exgentic=={ver}"] + + +def require_uv() -> str: + """Return the path to ``uv``, raising a clear error if not found.""" + uv = shutil.which("uv") + if uv is None: + raise RuntimeError( + "Could not find 'uv' on PATH. " "Install it with: curl -LsSf https://astral.sh/uv/install.sh | sh" + ) + return uv + + +_ENV_BLOCKLIST: frozenset[str] = frozenset( + { + "VIRTUAL_ENV", + "CONDA_DEFAULT_ENV", + "CONDA_PREFIX", + } +) +_ENV_PREFIX_BLOCKLIST: tuple[str, ...] = ("UV_", "PIP_", "VSCODE_") + + +def build_subprocess_env() -> dict: + """Build a filtered env dict for subprocess calls. + + Strips virtual-env manager vars and package-tool overrides + (``UV_*``, ``PIP_*``) that could redirect package installs or + change the Python version used by uv/pip. Preserves ``PATH``, + ``HOME``, and other vars needed for tools to run. + """ + env = { + k: v + for k, v in os.environ.items() + if k not in _ENV_BLOCKLIST and not any(k.startswith(p) for p in _ENV_PREFIX_BLOCKLIST) + } + env["GIT_LFS_SKIP_SMUDGE"] = "1" + return env + + +def install_project(uv: str, python_target: str, project_root: Path, env: dict) -> None: + """Install a Python project from *project_root* into the target Python.""" + subprocess.run( + [uv, "pip", "install", "--python", python_target, "--no-cache", str(project_root)], + check=True, + capture_output=True, + text=True, + env=env, + ) + + +def install_packages(uv: str, python_target: str, packages: list[str], env: dict) -> None: + """Install packages into the target Python environment.""" + subprocess.run( + [uv, "pip", "install", "--python", python_target, "--no-cache", *packages], + check=True, + capture_output=True, + text=True, + env=env, + ) + + +def install_requirements(uv: str, python_target: str, module_path: str, env: dict) -> None: + """Find and install requirements.txt into the target Python.""" + req_path = find_package_file(module_path, "requirements.txt") + if req_path is None: + return + lines = [ + line.strip() for line in req_path.read_text().splitlines() if line.strip() and not line.strip().startswith("#") + ] + if not lines: + return + subprocess.run( + [uv, "pip", "install", "--python", python_target, "-r", str(req_path)], + check=True, + capture_output=True, + text=True, + env=env, + ) + + +def run_setup_sh(module_path: str, env_dir: Path, *, venv_dir: Path | None = None) -> None: + """Run setup.sh in the environment directory. + + Args: + module_path: Dotted module path for locating setup.sh. + env_dir: Working directory for setup.sh execution. + venv_dir: If set, activates the venv in the subprocess. + """ + setup_path = find_package_file(module_path, "setup.sh") + if setup_path is None: + return + env = build_subprocess_env() + if venv_dir is not None: + env["VIRTUAL_ENV"] = str(venv_dir) + env["PATH"] = str(venv_dir / "bin") + os.pathsep + env.get("PATH", "") + subprocess.run(["bash", str(setup_path)], check=True, cwd=str(env_dir), env=env) + + +def find_package_file(module_path: str, filename: str) -> Path | None: + """Locate *filename* in the package directory for *module_path*.""" + parts = module_path.split(".") + for depth in range(len(parts) - 1, 1, -1): + package = ".".join(parts[:depth]) + try: + candidate = resources.files(package) / filename + except Exception: + continue + if candidate.is_file(): + return Path(str(candidate)) + return None + + +def read_lines(path: Path) -> list[str]: + """Read non-empty, non-comment lines from *path*.""" + return [line.strip() for line in path.read_text().splitlines() if line.strip() and not line.strip().startswith("#")] + + +def validate_system_deps(module_path: str) -> None: + """Check that system packages from ``system-deps.txt`` are installed.""" + sysdeps_path = find_package_file(module_path, "system-deps.txt") + if sysdeps_path is None: + return + pkgs = read_lines(sysdeps_path) + if not pkgs: + return + missing = [p for p in pkgs if shutil.which(p) is None and not _dpkg_installed(p)] + if missing: + raise RuntimeError( + f"Missing system packages required for install: {', '.join(missing)}. " + "Install them with: sudo apt-get install -y " + " ".join(missing) + ) + + +def _dpkg_installed(package: str) -> bool: + """Return *True* if *package* is installed via dpkg.""" + if shutil.which("dpkg") is None: + return False + result = subprocess.run( + ["dpkg", "-s", package], + check=False, + capture_output=True, + text=True, + ) + return result.returncode == 0 diff --git a/labs/AgentStream/exgentic/src/exgentic/environment/instance.py b/labs/AgentStream/exgentic/src/exgentic/environment/instance.py new file mode 100644 index 00000000..dcf8be6e --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/environment/instance.py @@ -0,0 +1,19 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Shared EnvironmentManager instance.""" + +from __future__ import annotations + +from pathlib import Path + +from .manager import EnvironmentManager + + +def get_manager() -> EnvironmentManager: + """Return the shared EnvironmentManager instance. + + Environments live at ``~/.exgentic/`` — a fixed absolute path, + independent of the working directory or cache settings. + """ + return EnvironmentManager(base_dir=Path.home() / ".exgentic") diff --git a/labs/AgentStream/exgentic/src/exgentic/environment/local.py b/labs/AgentStream/exgentic/src/exgentic/environment/local.py new file mode 100644 index 00000000..7a43eb9f --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/environment/local.py @@ -0,0 +1,68 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Local environment: installs dependencies into the current Python.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from .helpers import ( + build_subprocess_env, + install_packages, + install_project, + install_requirements, + require_uv, + run_setup_sh, + validate_system_deps, +) + + +class LocalBackend: + """Backend that installs dependencies into the current Python.""" + + def install( + self, + env_dir: Path, + *, + module_path: str | None = None, + **kwargs: object, + ) -> dict: + """Install dependencies into the current Python environment. + + Args: + env_dir: Root directory for data/markers. + module_path: Dotted module path for locating package resources. + **kwargs: Accepts ``project_root`` (Path) and ``packages`` (list). + + Returns: + Extra marker data (``python`` path). + """ + project_root: Path | None = kwargs.get("project_root") # type: ignore[assignment] + packages: list[str] | None = kwargs.get("packages") # type: ignore[assignment] + + if project_root is not None or packages or module_path is not None: + uv = require_uv() + env = build_subprocess_env() + # Ensure VIRTUAL_ENV is set so uv installs into the correct + # environment when the current Python lives inside a venv + # (e.g. when exgentic is installed as a uv tool). + if sys.prefix != sys.base_prefix: + env["VIRTUAL_ENV"] = sys.prefix + + if project_root is not None: + install_project(uv, sys.executable, project_root, env) + + if packages: + install_packages(uv, sys.executable, packages, env) + + if module_path is not None: + install_requirements(uv, sys.executable, module_path, env) + validate_system_deps(module_path) + run_setup_sh(module_path, env_dir) + + return {"python": sys.executable} + + def uninstall(self, env_dir: Path, marker_data: dict) -> None: + """No-op. Cannot remove deps from the current Python environment.""" diff --git a/labs/AgentStream/exgentic/src/exgentic/environment/manager.py b/labs/AgentStream/exgentic/src/exgentic/environment/manager.py new file mode 100644 index 00000000..2cfe8d24 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/environment/manager.py @@ -0,0 +1,232 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Environment manager: orchestrates install, uninstall, and queries.""" + +from __future__ import annotations + +import json +import shutil +from datetime import datetime, timezone +from enum import StrEnum +from pathlib import Path + +from .docker import DockerBackend +from .local import LocalBackend +from .protocol import EnvironmentBackend +from .venv import VenvBackend + + +class EnvType(StrEnum): + """Supported environment types.""" + + VENV = "venv" + LOCAL = "local" + DOCKER = "docker" + + +class EnvironmentManager: + """Manages isolated environments identified by name. + + Each environment lives at ``{base_dir}/{name}/`` and can have + multiple environment types (venv, local, docker) installed + simultaneously. The ``.installed`` marker file tracks which + types are present as a JSON dict keyed by :class:`EnvType`. + """ + + MARKER_FILE = ".installed" + + def __init__(self, base_dir: Path | None = None) -> None: + self.base_dir = base_dir or Path.home() / ".exgentic" + self._backends: dict[EnvType, EnvironmentBackend] = { + EnvType.VENV: VenvBackend(), + EnvType.LOCAL: LocalBackend(), + EnvType.DOCKER: DockerBackend(), + } + + # ------------------------------------------------------------------ + # Core operations + # ------------------------------------------------------------------ + + def install( + self, + name: str, + *, + env_type: EnvType = EnvType.VENV, + force: bool = False, + module_path: str | None = None, + project_root: Path | None = None, + packages: list[str] | None = None, + docker_socket: bool = False, + ) -> Path: + """Install an environment. + + Args: + name: Environment name (e.g. ``"benchmarks/tau2"``). + env_type: Type of environment to create. + force: Re-create even if already installed. + module_path: Dotted module path for locating package resources. + project_root: Root of a Python project to install into the env. + packages: Extra pip packages to install. + docker_socket: (Docker only) Install the Docker CLI binary so the + container can manage sibling containers via the mounted socket. + + Returns: + The environment directory path. + """ + env_type = EnvType(env_type) + if not force and self.is_installed(name, env_type=env_type): + return self.env_path(name) + + env_dir = self.env_path(name) + env_dir.mkdir(parents=True, exist_ok=True) + self._remove_marker_entry(name, env_type) + + backend = self._backends[env_type] + + # Build kwargs for the backend. + kwargs: dict[str, object] = {} + if project_root is not None: + kwargs["project_root"] = project_root + if packages is not None: + kwargs["packages"] = packages + if env_type is EnvType.DOCKER: + kwargs["name"] = name + kwargs["force"] = force + kwargs["docker_socket"] = docker_socket + + extra = backend.install(env_dir, module_path=module_path, **kwargs) + self._add_marker_entry(name, env_type, {"installed_at": _now_iso(), **extra}) + + return env_dir + + def uninstall(self, name: str, *, env_type: EnvType | None = None) -> None: + """Remove an installed environment. + + Args: + name: Environment name. + env_type: Specific type to remove, or *None* to remove all. + """ + if env_type is not None: + env_type = EnvType(env_type) + + env_dir = self.env_path(name) + if not env_dir.exists(): + return + + marker = self._read_marker(name) + + if env_type is None: + # Remove all env types, then the whole directory. + for et in EnvType: + if et in marker: + self._backends[et].uninstall(env_dir, marker.get(et, {})) + shutil.rmtree(env_dir) + return + + # Remove a single env type. + self._backends[env_type].uninstall(env_dir, marker.get(env_type, {})) + self._remove_marker_entry(name, env_type) + + # Clean up directory if no env types remain. + if not self._read_marker(name) and env_dir.exists(): + shutil.rmtree(env_dir) + + # ------------------------------------------------------------------ + # Queries + # ------------------------------------------------------------------ + + def is_installed(self, name: str, *, env_type: EnvType | None = None) -> bool: + """Check if an environment is installed. + + Args: + name: Environment name. + env_type: Check a specific type, or *None* for any. + """ + if env_type is not None: + env_type = EnvType(env_type) + marker = self._read_marker(name) + if env_type is None: + return bool(marker) + return env_type in marker + + def get_info(self, name: str) -> dict | None: + """Return installation info or *None* if not installed.""" + marker = self._read_marker(name) + if not marker: + return None + return {"name": name, "environments": marker} + + def list_installed(self) -> list[dict]: + """List all installed environments with details.""" + result: list[dict] = [] + if not self.base_dir.is_dir(): + return result + for child in sorted(self.base_dir.rglob(self.MARKER_FILE)): + try: + marker = json.loads(child.read_text()) + except (json.JSONDecodeError, ValueError): + continue + if isinstance(marker, dict) and marker: + name = str(child.parent.relative_to(self.base_dir)) + result.append({"name": name, "environments": marker}) + return result + + # ------------------------------------------------------------------ + # Paths & accessors + # ------------------------------------------------------------------ + + def env_path(self, name: str) -> Path: + """Return the environment directory path.""" + return self.base_dir / name + + def venv_python(self, name: str) -> str: + """Return the path to the venv Python binary.""" + return str(self.env_path(name) / "venv" / "bin" / "python") + + def docker_image(self, name: str) -> str | None: + """Return the Docker image tag, or *None* if not installed.""" + return self._read_marker(name).get(EnvType.DOCKER, {}).get("image") + + def local_python(self, name: str) -> str | None: + """Return the Python path used for local install, or *None*.""" + return self._read_marker(name).get(EnvType.LOCAL, {}).get("python") + + # ------------------------------------------------------------------ + # Marker management + # ------------------------------------------------------------------ + + def _read_marker(self, name: str) -> dict: + marker = self.env_path(name) / self.MARKER_FILE + if not marker.is_file(): + return {} + try: + data = json.loads(marker.read_text()) + return data if isinstance(data, dict) else {} + except (json.JSONDecodeError, ValueError): + return {} + + def _write_marker(self, name: str, data: dict) -> None: + env_dir = self.env_path(name) + env_dir.mkdir(parents=True, exist_ok=True) + (env_dir / self.MARKER_FILE).write_text(json.dumps(data, indent=2)) + + def _add_marker_entry(self, name: str, env_type: EnvType, info: dict) -> None: + data = self._read_marker(name) + data[env_type] = info + self._write_marker(name, data) + + def _remove_marker_entry(self, name: str, env_type: EnvType) -> None: + data = self._read_marker(name) + data.pop(env_type, None) + if data: + self._write_marker(name, data) + else: + marker = self.env_path(name) / self.MARKER_FILE + if marker.exists(): + marker.unlink() + + +def _now_iso() -> str: + """Return the current UTC time as an ISO 8601 string.""" + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") diff --git a/labs/AgentStream/exgentic/src/exgentic/environment/protocol.py b/labs/AgentStream/exgentic/src/exgentic/environment/protocol.py new file mode 100644 index 00000000..d1cf5f53 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/environment/protocol.py @@ -0,0 +1,39 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Common protocol for environment backends.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Protocol + + +class EnvironmentBackend(Protocol): + """Uniform interface every environment backend must satisfy.""" + + def install(self, env_dir: Path, *, module_path: str | None = None, **kwargs: object) -> dict: + """Create / set up the environment. + + Args: + env_dir: Root directory for this environment. + module_path: Dotted module path for locating package resources. + **kwargs: Common options forwarded by the manager: + ``project_root`` (Path | None) - root of a Python project to install. + ``packages`` (list[str] | None) - extra pip packages to install. + Backends may also receive backend-specific options (e.g. ``name``, + ``force`` for Docker). + + Returns: + Extra marker data to persist (may be empty). + """ + ... + + def uninstall(self, env_dir: Path, marker_data: dict) -> None: + """Tear down the environment. + + Args: + env_dir: Root directory for this environment. + marker_data: Data previously stored in the marker file. + """ + ... diff --git a/labs/AgentStream/exgentic/src/exgentic/environment/venv.py b/labs/AgentStream/exgentic/src/exgentic/environment/venv.py new file mode 100644 index 00000000..fdafd39b --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/environment/venv.py @@ -0,0 +1,85 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Venv environment: creates an isolated Python venv with dependencies.""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +from .helpers import ( + build_subprocess_env, + install_packages, + install_project, + install_requirements, + require_uv, + run_setup_sh, + validate_system_deps, +) + + +class VenvBackend: + """Backend that creates an isolated Python venv with dependencies.""" + + def install( + self, + env_dir: Path, + *, + module_path: str | None = None, + **kwargs: object, + ) -> dict: + """Create a venv-based environment. + + Args: + env_dir: Root directory for this environment. + module_path: Dotted module path for locating package resources. + **kwargs: Accepts ``project_root`` (Path) and ``packages`` (list). + + Returns: + Empty dict (no extra marker data). + """ + project_root: Path | None = kwargs.get("project_root") # type: ignore[assignment] + packages: list[str] | None = kwargs.get("packages") # type: ignore[assignment] + + venv_dir = env_dir / "venv" + if venv_dir.exists(): + shutil.rmtree(venv_dir) + + try: + uv = require_uv() + + subprocess.run( + [uv, "venv", str(venv_dir), "--python", f"{sys.version_info.major}.{sys.version_info.minor}"], + check=True, + capture_output=True, + text=True, + ) + + venv_py = str(venv_dir / "bin" / "python") + env = build_subprocess_env() + + if project_root is not None: + install_project(uv, venv_py, project_root, env) + + if packages: + install_packages(uv, venv_py, packages, env) + + if module_path is not None: + install_requirements(uv, venv_py, module_path, env) + validate_system_deps(module_path) + run_setup_sh(module_path, env_dir, venv_dir=venv_dir) + except BaseException: + if venv_dir.exists(): + shutil.rmtree(venv_dir, ignore_errors=True) + raise + + return {} + + def uninstall(self, env_dir: Path, marker_data: dict) -> None: + """Remove the venv directory.""" + venv_dir = env_dir / "venv" + if venv_dir.exists(): + shutil.rmtree(venv_dir) diff --git a/labs/AgentStream/exgentic/src/exgentic/integrations/__init__.py b/labs/AgentStream/exgentic/src/exgentic/integrations/__init__.py new file mode 100644 index 00000000..ed18cc54 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/integrations/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +__all__ = [] diff --git a/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/__init__.py b/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/__init__.py new file mode 100644 index 00000000..a5415540 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/__init__.py @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import os + +from .health import acheck_model_accessible +from .proxy import LitellmProxy +from .trace_cost import load_trace_cost +from .trace_logger import ( + DEFAULT_FILE, + FILE_ENV, + TraceLogger, + trace_logger, +) + +# When running inside the LiteLLM proxy subprocess, eagerly initialise the +# Exgentic cache so that ``litellm.cache`` is set before any request arrives. +# The parent process sets EXGENTIC_PROXY_CACHE_INIT=true when it launches the +# proxy with disk caching enabled. +if os.environ.get("EXGENTIC_PROXY_CACHE_INIT", "").lower() in ("true", "1"): + from ...utils.settings import get_settings + + get_settings() + +__all__ = [ + "LitellmProxy", + "trace_logger", + "TraceLogger", + "FILE_ENV", + "DEFAULT_FILE", + "load_trace_cost", + "acheck_model_accessible", +] diff --git a/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/cache/__init__.py b/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/cache/__init__.py new file mode 100644 index 00000000..7966f185 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/cache/__init__.py @@ -0,0 +1,26 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Litellm cache package.""" + +from .core import LLMCache, build_litellm_cache +from .key import ( + CacheKeyBuilder, + MessageNormalizer, + sanitize_messages_for_cache, + strip_date_time_from_text, +) +from .log import CacheLogger + +CustomCache = LLMCache + +__all__ = [ + "CacheKeyBuilder", + "CacheLogger", + "CustomCache", + "LLMCache", + "MessageNormalizer", + "build_litellm_cache", + "sanitize_messages_for_cache", + "strip_date_time_from_text", +] diff --git a/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/cache/core.py b/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/cache/core.py new file mode 100644 index 00000000..2d23ee09 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/cache/core.py @@ -0,0 +1,325 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Litellm cache implementation using deterministic keying.""" + +from __future__ import annotations + +import json +import time +from typing import Any, Optional + +from litellm.caching.caching import Cache +from litellm.main import ModelResponse + +from ....utils.settings import resolve_cache_path +from .key import ( + _NOT_HANDLED, + CacheKeyBuilder, + MessageNormalizer, + _hash_text, + _resolve_custom_param, +) +from .log import CacheLogger + +# --------------------------------------------------------------------------- +# Cache key store (sync/async boundary bookkeeping) +# --------------------------------------------------------------------------- + + +class CacheKeyStore: + """Remembers cache keys so add_cache can reuse the key from get_cache. + + Litellm's callback pipeline may lose/regenerate keys between the get and + add phases, especially in async streaming. + """ + + def __init__(self) -> None: + self._sync: dict[str, str] = {} + self._async: dict[str, str] = {} + + @staticmethod + def _valid(call_id: Any) -> bool: + return isinstance(call_id, str) + + # -- sync -- + + def remember_sync(self, call_id: Any, key: Optional[str]) -> None: + if self._valid(call_id) and key: + self._sync[call_id] = key + + def inject_sync_key(self, kwargs: dict[str, Any]) -> None: + call_id = kwargs.get("litellm_call_id") + if not self._valid(call_id) or "cache_key" in kwargs: + return + stored = self._sync.pop(call_id, None) + if stored is not None: + kwargs["cache_key"] = stored + + # -- async -- + + def remember_async(self, call_id: Any, key: Optional[str]) -> None: + if self._valid(call_id) and key: + self._async[call_id] = key + + def pop_async(self, call_id: Any) -> Optional[str]: + return self._async.pop(call_id, None) if self._valid(call_id) else None + + def clear_async(self, call_id: Any) -> None: + if self._valid(call_id): + self._async.pop(call_id, None) + + +# --------------------------------------------------------------------------- +# Cache helpers +# --------------------------------------------------------------------------- + + +def _is_empty_response(result: Any) -> bool: + """True if result is None or a ModelResponse with no content/tool_calls.""" + if result is None: + return True + if not isinstance(result, ModelResponse): + return False + msg = result.choices[0].message + return msg.content is None and not msg.tool_calls and not msg.function_call + + +def _extract_max_age(kwargs: dict[str, Any]) -> float: + cc = kwargs.get("cache", {}) + return cc.get("s-maxage") or cc.get("s-max-age") or float("inf") + + +def _classify_miss(cached_result: Any, max_age: float) -> tuple[str, Optional[int]]: + if isinstance(cached_result, dict) and "timestamp" in cached_result and max_age is not None: + age = time.time() - cached_result["timestamp"] + if age > max_age: + return "expired", int(age) + return "not_found", None + + +def _dump_raw_key(raw_key: Optional[str]) -> Optional[str]: + if raw_key is None: + return None + return json.dumps(raw_key, ensure_ascii=True, separators=(",", ":")) + + +# --------------------------------------------------------------------------- +# Cache +# --------------------------------------------------------------------------- + + +class LLMCache(Cache): + """Disk-backed LLM response cache with deterministic keys and diagnostics.""" + + def __init__(self, *, delete_time_from_messages: bool = False, **kwargs: Any) -> None: + self._normalizer = MessageNormalizer(strip_time=delete_time_from_messages) + self._key_store = CacheKeyStore() + self._log = CacheLogger( + disk_cache_dir=kwargs.get("disk_cache_dir"), + strip_time=delete_time_from_messages, + ) + super().__init__(**kwargs) + # Must be created after super().__init__ so self is a valid Cache instance. + self._key_builder = CacheKeyBuilder(self._normalizer, self) + + @property + def logger(self) -> Any: + return self._log.raw + + # -- litellm Cache interface -- + + def get_cache_key(self, **kwargs: Any) -> str: # type: ignore[override] + key, _, _, _ = self._key_builder.build(kwargs) + return key or "" + + def get_cache(self, dynamic_cache_object: Any = None, **kwargs: Any) -> Any: # type: ignore[override] + if self.should_use_cache(**kwargs) is not True: + self._log.detail("skip", reason="disabled", model=kwargs.get("model")) + return None + + key, raw_key, source, _ = self._build_key_logged(kwargs) + max_age = _extract_max_age(kwargs) + self._key_store.remember_sync(kwargs.get("litellm_call_id"), key) + + if key is None: + self._log_miss(reason="cache_key_none", source=source, kwargs=kwargs) + return None + + backend = dynamic_cache_object if dynamic_cache_object is not None else self.cache + cached_result = backend.get_cache(key, **kwargs) + return self._finalize_lookup(cached_result, key, source, raw_key, kwargs, max_age) + + async def async_get_cache(self, dynamic_cache_object: Any = None, **kwargs: Any) -> Any: # type: ignore[override] + if self.should_use_cache(**kwargs) is not True: + self._log.detail("skip", reason="disabled", model=kwargs.get("model")) + return None + + key, raw_key, source, _ = self._build_key_logged(kwargs) + max_age = _extract_max_age(kwargs) + self._key_store.remember_async(kwargs.get("litellm_call_id"), key) + + if key is None: + self._log_miss(reason="cache_key_none", source=source, kwargs=kwargs) + return None + + if dynamic_cache_object is not None: + cached_result = await dynamic_cache_object.async_get_cache(key, **kwargs) + else: + cached_result = await self.cache.async_get_cache(key, **kwargs) + return self._finalize_lookup(cached_result, key, source, raw_key, kwargs, max_age) + + def add_cache(self, result: Any, **kwargs: Any) -> None: # type: ignore[override] + self._log.detail( + "add_cache_called", + result_type=type(result).__name__, + model=kwargs.get("model"), + ) + if _is_empty_response(result): + self._log.detail("skip", reason="empty_assistant_content", model=kwargs.get("model")) + return + self._key_store.inject_sync_key(kwargs) + super().add_cache(result, **kwargs) + self._log.detail( + "add_cache_written", + result_type=type(result).__name__, + model=kwargs.get("model"), + ) + + async def async_add_cache(self, result: Any, dynamic_cache_object: Any = None, **kwargs: Any) -> None: # type: ignore[override] + call_id = kwargs.get("litellm_call_id") + self._log.detail( + "async_add_cache_called", + result_type=type(result).__name__, + model=kwargs.get("model"), + call_id=call_id, + ) + + if _is_empty_response(result): + self._key_store.clear_async(call_id) + self._log.detail("skip", reason="empty_assistant_content", model=kwargs.get("model")) + return + + stored_key = self._key_store.pop_async(call_id) + if stored_key: + self._verify_async_key_if_debug(stored_key, kwargs) + cached_data = {"timestamp": time.time(), "response": result} + if dynamic_cache_object is not None: + await dynamic_cache_object.async_set_cache(stored_key, cached_data, **kwargs) + else: + await self.cache.async_set_cache(stored_key, cached_data, **kwargs) + self._log.detail( + "async_add_cache_written", + key=stored_key, + result_type=type(result).__name__, + model=kwargs.get("model"), + ) + return + + await super().async_add_cache(result, dynamic_cache_object=dynamic_cache_object, **kwargs) + self._log.detail( + "async_add_cache_written", + result_type=type(result).__name__, + model=kwargs.get("model"), + ) + + # Override so litellm's parent class uses our normalizer. + def _get_param_value(self, param: str, kwargs: dict) -> Optional[str]: # type: ignore[override] + result = _resolve_custom_param(param, kwargs, self._normalizer) + if result is not _NOT_HANDLED: + return result + return super()._get_param_value(param, kwargs) + + # -- shared lookup helpers -- + + def _finalize_lookup( + self, + cached_result: Any, + key: str, + source: str, + raw_key: Optional[str], + kwargs: dict[str, Any], + max_age: float, + ) -> Any: + """Apply max-age logic and log the outcome. Shared by sync and async get.""" + result = self._get_cache_logic(cached_result=cached_result, max_age=max_age) + if result is not None: + self._log.hit() + self._log.detail("hit", key=key, key_source=source, model=kwargs.get("model")) + else: + reason, age_seconds = _classify_miss(cached_result, max_age) + self._log.miss() + self._log.detail( + "miss", + reason=reason, + key=key, + key_source=source, + raw_key=_dump_raw_key(raw_key), + model=kwargs.get("model"), + age=age_seconds, + max_age=None if max_age == float("inf") else max_age, + ) + return result + + def _log_miss(self, *, reason: str, source: str, kwargs: dict[str, Any]) -> None: + self._log.miss() + self._log.detail("miss", reason=reason, model=kwargs.get("model"), source=source) + + def _build_key_logged(self, kwargs: dict[str, Any]) -> tuple[Optional[str], Optional[str], str, list[str]]: + if self._log.is_debug: + from .key import _EXCLUDED_KEY_FIELDS + + self._log.detail( + "cache_key_input", + keys=sorted(kwargs.keys()), + excluded=sorted(_EXCLUDED_KEY_FIELDS), + ) + + key, raw_key, source, fields = self._key_builder.build(kwargs) + + if self._log.is_debug: + if source in ("explicit", "preset"): + self._log.detail(f"cache_key_{source}", key=key) + else: + raw_hash = _hash_text(raw_key) if raw_key else None + self._log.detail( + "cache_key_generated", + key=key, + raw_key=_dump_raw_key(raw_key), + raw_key_hash=raw_hash, + raw_key_len=len(raw_key) if raw_key else 0, + ) + self._log.material( + { + "cache_key": key, + "raw_key_hash": raw_hash, + "raw_key_len": len(raw_key) if raw_key else 0, + "included_fields": fields, + } + ) + + return key, raw_key, source, fields + + def _verify_async_key_if_debug(self, stored_key: str, kwargs: dict[str, Any]) -> None: + if not self._log.is_debug: + return + current_key, _, _, _ = self._key_builder.build(dict(kwargs)) + if current_key and current_key != stored_key: + self._log.detail("cache_key_mismatch", stored_key=stored_key, current_key=current_key) + else: + self._log.detail("cache_key_async_reuse", key=stored_key) + + +# --------------------------------------------------------------------------- +# Factory +# --------------------------------------------------------------------------- + + +def build_litellm_cache(settings: Any) -> Cache: + cache_dir = getattr(settings, "cache_dir", ".exgentic") + litellm_cache_dir = getattr(settings, "litellm_cache_dir", "~/.cache/exgentic/litellm") + return LLMCache( + type="disk", + disk_cache_dir=resolve_cache_path(cache_dir, litellm_cache_dir), + delete_time_from_messages=settings.litellm_delete_time_from_cache_key, + ) diff --git a/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/cache/key.py b/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/cache/key.py new file mode 100644 index 00000000..cd06ec01 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/cache/key.py @@ -0,0 +1,289 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Cache key construction and message normalization.""" + +from __future__ import annotations + +import hashlib +import json +import re +from typing import Any, Optional + +from litellm.caching.caching import Cache, ModelParamHelper, litellm +from litellm.types.utils import all_litellm_params + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + + +def _to_stable_json(value: Any) -> str: + """Deterministic JSON string; falls back to str() for non-serializable types.""" + if not isinstance(value, (dict, list)): + return str(value) + try: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + except TypeError: + return str(value) + + +def _hash_text(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +# --------------------------------------------------------------------------- +# 1. Message normalization +# --------------------------------------------------------------------------- + +_MONTH_PATTERN = ( + r"(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|" + r"Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)" +) + +_TODAYS_DATE_REGEX = re.compile(r"(?i)\bToday'?s date(?:\s+is|:)\s*[^\n]*") + +_DATE_TIME_REGEXES = [ + re.compile(r"\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?" r"(?:Z|[+-]\d{2}:\d{2})?\b"), + re.compile(r"\b\d{4}[-/]\d{1,2}[-/]\d{1,2}\b"), + re.compile(r"\b\d{1,2}[-/]\d{1,2}[-/]\d{2,4}\b"), + re.compile( + rf"\b{_MONTH_PATTERN}\s+\d{{1,2}}(?:st|n(?:d)|rd|th)?(?:,\s*\d{{2}}|\s+\d{{4}})?\b", + re.IGNORECASE, + ), + re.compile( + rf"\b\d{{1,2}}(?:st|n(?:d)|rd|th)?\s+{_MONTH_PATTERN}(?:,\s*\d{{2}}|\s+\d{{4}})?\b", + re.IGNORECASE, + ), + re.compile(r"\b\d{1,2}:\d{2}(?::\d{2})?(?:\s?[AaPp][Mm])?\b"), + re.compile(r"\b\d{1,2}\s?(?:[AaPp][Mm])\b"), +] + + +class MessageNormalizer: + """Produces stable, cache-friendly representations of LLM message lists. + + Strips "Today's date" lines and date/time literals (when requested), + removes non-deterministic IDs, drops None values and empty assistant messages. + """ + + def __init__(self, strip_time: bool) -> None: + self._strip_time = strip_time + + def normalize(self, messages: Any) -> Any: + normalized = self._normalize_value(messages) + if isinstance(normalized, list): + normalized = [m for m in normalized if not self._is_empty_assistant(m)] + return normalized + + def to_json(self, messages: Any) -> Optional[str]: + """Normalize then serialize. Returns None if messages is None.""" + if messages is None: + return None + normalized = self.normalize(messages) + try: + return json.dumps(normalized, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + except TypeError: + return str(normalized) + + # -- recursive value cleaning -- + + def _normalize_value(self, value: Any) -> Any: + if isinstance(value, str): + return self._clean_string(value) + if isinstance(value, list): + return [self._normalize_value(item) for item in value] + if isinstance(value, dict): + return self._clean_dict(value) + for attr in ("model_dump", "dict"): + fn = getattr(value, attr, None) + if callable(fn): + return self._normalize_value(fn()) + return value + + def _clean_string(self, text: str) -> str: + cleaned = _TODAYS_DATE_REGEX.sub("", text) + if self._strip_time: + cleaned = _strip_date_time_literals(cleaned) + return cleaned + + def _clean_dict(self, d: dict) -> dict: + is_tool_msg = d.get("role") == "tool" + result: dict[Any, Any] = {} + for key, val in d.items(): + if val is None or key == "tool_call_id": + continue + if key == "id" and is_tool_msg: + continue + if key == "tool_calls" and isinstance(val, list): + result[key] = [ + self._normalize_value( + {k: v for k, v in call.items() if k != "id"} + if isinstance(call, dict) and "id" in call + else call + ) + for call in val + ] + continue + result[key] = self._normalize_value(val) + return result + + @staticmethod + def _is_empty_assistant(msg: Any) -> bool: + return ( + isinstance(msg, dict) + and msg.get("role") == "assistant" + and isinstance(msg.get("content"), str) + and msg.get("content", "").strip() == "(no content)" + ) + + +def _strip_date_time_literals(text: str) -> str: + if not text: + return text + for regex in _DATE_TIME_REGEXES: + text = regex.sub("", text) + return re.sub(r"\s+", " ", text).strip() + + +# Backward-compatible module-level aliases. +strip_date_time_from_text = _strip_date_time_literals + + +def sanitize_messages_for_cache(messages: Any, *, strip_time: bool) -> Any: + return MessageNormalizer(strip_time).normalize(messages) + + +# --------------------------------------------------------------------------- +# 2. Cache key building +# --------------------------------------------------------------------------- + +_EXCLUDED_KEY_FIELDS = frozenset( + { + "litellm_call_id", + "litellm_trace_id", + "litellm_logging_obj", + "litellm_metadata", + "proxy_server_request", + "parent_otel_span", + "secret_fields", + "shared_session", + "use_in_pass_through", + "use_litellm_proxy", + "model_info", + "provider_specific_header", + "user", + "dynamic_cache_object", + } +) + + +def _sort_tools(tools: Any) -> Any: + """Return tools in a deterministic order for stable cache keys.""" + if not isinstance(tools, list): + return tools + + def sort_key(item: Any) -> tuple[str, str, str]: + if not isinstance(item, dict): + return ("", "", str(item)) + tool_type = str(item.get("type", "")) + func = item.get("function") + func_name = str(func.get("name", "")) if isinstance(func, dict) else "" + try: + stable = json.dumps(item, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + except TypeError: + stable = str(item) + return (tool_type, func_name, stable) + + return sorted(tools, key=sort_key) + + +_NOT_HANDLED = object() + + +def _resolve_custom_param(param: str, kwargs: dict[str, Any], normalizer: MessageNormalizer) -> Any: + """Resolve params that need custom serialization. + + Returns the string value for params we handle (messages, tools, sentinels), + or _NOT_HANDLED for params that should fall through to litellm's default. + + Shared by CacheKeyBuilder and LLMCache._get_param_value so the same + serialization logic is never duplicated. + """ + if param in ("litellm_logging_obj", "litellm_call_id"): + return param # sentinel — present but content-irrelevant + if param == "tools": + return json.dumps(_sort_tools(kwargs.get("tools", [])), sort_keys=True) + if param == "messages": + return normalizer.to_json(kwargs.get("messages")) + return _NOT_HANDLED + + +class CacheKeyBuilder: + """Builds deterministic cache keys from LLM call kwargs. + + Pure logic — no logging, no side effects. + """ + + def __init__(self, normalizer: MessageNormalizer, cache: Cache) -> None: + self._normalizer = normalizer + self._cache = cache + + def build(self, kwargs: dict[str, Any]) -> tuple[Optional[str], Optional[str], str, list[str]]: + """Return (cache_key, raw_key, source, included_fields).""" + if "cache_key" in kwargs: + return kwargs["cache_key"], None, "explicit", [] + + preset = self._cache._get_preset_cache_key_from_kwargs(**kwargs) + if preset is not None: + return preset, None, "preset", [] + + raw_key, included_fields = self._build_raw_key(kwargs) + + hashed = Cache._get_hashed_cache_key(raw_key) + # Ensure metadata is a dict — litellm's _add_namespace_to_cache_key + # does metadata.get(...) which crashes when metadata is None + # (happens on the Responses API path used by Claude Code). + safe_kwargs = kwargs + if kwargs.get("metadata") is None and "metadata" in kwargs: + safe_kwargs = {**kwargs, "metadata": {}} + hashed = self._cache._add_namespace_to_cache_key(hashed, **safe_kwargs) + self._cache._set_preset_cache_key_in_kwargs(preset_cache_key=hashed, **kwargs) + + return hashed, raw_key, "generated", included_fields + + def _build_raw_key(self, kwargs: dict[str, Any]) -> tuple[str, list[str]]: + filtered = {k: v for k, v in kwargs.items() if k not in _EXCLUDED_KEY_FIELDS} + combined_params = ModelParamHelper._get_all_llm_api_params() + litellm_params = all_litellm_params + + raw_key = "" + included: list[str] = [] + + for param in sorted(filtered): + value = self._resolve(param, filtered, combined_params, litellm_params) + if value is not None: + raw_key += f"{param}: {value}" + included.append(param) + + return raw_key, included + + def _resolve( + self, + param: str, + kwargs: dict[str, Any], + combined: set[str], + litellm_only: set[str], + ) -> Optional[str]: + if param in combined: + result = _resolve_custom_param(param, kwargs, self._normalizer) + if result is not _NOT_HANDLED: + return result + # Fallback to litellm's default handling (e.g. model name normalization). + return self._cache._get_param_value(param, kwargs) + + if param not in litellm_only and litellm.enable_caching_on_provider_specific_optional_params: + value = kwargs[param] + return _to_stable_json(value) if value is not None else None + + return None diff --git a/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/cache/log.py b/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/cache/log.py new file mode 100644 index 00000000..f0f5d22a --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/cache/log.py @@ -0,0 +1,127 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Cache logging utilities.""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any, Optional + +from ....core.context import try_get_context +from ....utils.settings import get_settings + + +def _resolve_session_id() -> Optional[str]: + ctx = try_get_context() + if ctx is not None: + return ctx.session_id + return None + + +def _cache_log_path(output_dir: str, run_id: str, session_id: Optional[str], role: str, filename: str) -> Path: + base = Path(output_dir) / run_id + if session_id: + return base / "sessions" / session_id / role / "litellm" / filename + return base / "run" / "litellm" / filename + + +class CacheLogger: + """File-based cache logger scoped to run + session. + + INFO - "hit" / "miss" for every lookup + DEBUG - structured detail lines and JSON material dumps + """ + + def __init__(self, disk_cache_dir: Optional[str], strip_time: bool) -> None: + self._disk_cache_dir = disk_cache_dir + self._strip_time = strip_time + self._logger: Optional[logging.Logger] = None + self._logger_key: Optional[tuple[str, str, Optional[str]]] = None + self._init_logged = False + + @property + def raw(self) -> logging.Logger: + """The underlying stdlib logger (for external consumers).""" + return self._ensure() + + @property + def is_debug(self) -> bool: + return self._ensure().isEnabledFor(logging.DEBUG) + + def hit(self) -> None: + self._ensure().info("hit") + + def miss(self) -> None: + self._ensure().info("miss") + + def detail(self, status: str, **fields: Any) -> None: + log = self._ensure() + if not log.isEnabledFor(logging.DEBUG): + return + parts = [status] + [f"{k}={v}" for k, v in fields.items() if v is not None] + log.debug(" ".join(parts)) + + def material(self, payload: dict[str, Any]) -> None: + log = self._ensure() + if log.isEnabledFor(logging.DEBUG): + log.debug( + "cache_material %s", + json.dumps(payload, ensure_ascii=True, separators=(",", ":")), + ) + + # -- private -- + + def _ensure(self) -> logging.Logger: + ctx = try_get_context() + if ctx is None: + return self._noop_logger() + + session_id = _resolve_session_id() + role = ctx.role.value if hasattr(ctx, "role") else "framework" + key = (ctx.run_id, ctx.output_dir, session_id, role) + if self._logger is not None and self._logger_key == key: + return self._logger + + name = f"exgentic.cache.{ctx.run_id}" + (f".{session_id}" if session_id else "") + logger = logging.getLogger(name) + + if self._logger_key != key: + for h in list(logger.handlers): + logger.removeHandler(h) + h.close() + self._init_logged = False + + if not logger.handlers: + path = _cache_log_path(ctx.output_dir, ctx.run_id, session_id, role, "cache.log") + path.parent.mkdir(parents=True, exist_ok=True) + level = logging.DEBUG if get_settings().log_level == "DEBUG" else logging.INFO + handler = logging.FileHandler(path, encoding="utf-8") + handler.setLevel(level) + handler.setFormatter(logging.Formatter("%(message)s")) + logger.addHandler(handler) + logger.setLevel(level) + logger.propagate = False + + if not self._init_logged and logger.isEnabledFor(logging.DEBUG): + logger.debug( + "cache_init disk_cache_dir=%s delete_time_from_messages=%s", + self._disk_cache_dir, + self._strip_time, + ) + self._init_logged = True + + self._logger = logger + self._logger_key = key + return logger + + def _noop_logger(self) -> logging.Logger: + logger = logging.getLogger("exgentic.cache.noop") + if not any(isinstance(h, logging.NullHandler) for h in logger.handlers): + logger.addHandler(logging.NullHandler()) + logger.propagate = False + self._logger = logger + self._logger_key = None + return logger diff --git a/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/cache_utils.py b/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/cache_utils.py new file mode 100644 index 00000000..1b782afa --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/cache_utils.py @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Compatibility wrapper for litellm cache utilities.""" + +from .cache import * # noqa: F403 diff --git a/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/config.py b/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/config.py new file mode 100644 index 00000000..561366c4 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/config.py @@ -0,0 +1,114 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""LiteLLM configuration helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class LitellmSettings: + """Global LiteLLM configuration.""" + + litellm_caching: bool + litellm_delete_time_from_cache_key: bool + cache_dir: str + litellm_cache_dir: str + log_level: str + drop_params: bool = True + modify_params: bool = True + timeout: int = 180 + + +def configure_litellm( + *, + config: LitellmSettings, + cache_only: bool = False, +) -> None: + """Configure LiteLLM for Exgentic. + + Args: + config: Explicit LiteLLM configuration. + cache_only: When True, only refresh cache configuration. + """ + _configure_cache(config) + if cache_only: + return + _configure_logging(config) + _configure_callbacks() + _configure_inference(config) + + +def _configure_cache(config: LitellmSettings) -> None: + if not config.litellm_caching: + return + try: + import litellm + except ImportError: + return + from .cache_utils import build_litellm_cache + + litellm.cache = build_litellm_cache(config) + litellm.enable_cache() + + +def _configure_callbacks() -> None: + try: + import litellm + except ImportError: + return + from .trace_logger import ( + AsyncTraceLogger, + SyncTraceLogger, + async_trace_logger, + sync_trace_logger, + ) + + if not any(isinstance(cb, SyncTraceLogger) for cb in litellm.success_callback): + litellm.success_callback.append(sync_trace_logger) + if not any(isinstance(cb, AsyncTraceLogger) for cb in litellm.success_callback): + litellm.success_callback.append(async_trace_logger) + if not any(isinstance(cb, SyncTraceLogger) for cb in litellm.failure_callback): + litellm.failure_callback.append(sync_trace_logger) + if not any(isinstance(cb, AsyncTraceLogger) for cb in litellm.failure_callback): + litellm.failure_callback.append(async_trace_logger) + if not any(isinstance(cb, SyncTraceLogger) for cb in litellm._async_success_callback): + litellm._async_success_callback.append(sync_trace_logger) + if not any(isinstance(cb, AsyncTraceLogger) for cb in litellm._async_success_callback): + litellm._async_success_callback.append(async_trace_logger) + if not any(isinstance(cb, SyncTraceLogger) for cb in litellm._async_failure_callback): + litellm._async_failure_callback.append(sync_trace_logger) + if not any(isinstance(cb, AsyncTraceLogger) for cb in litellm._async_failure_callback): + litellm._async_failure_callback.append(async_trace_logger) + + +def _configure_logging(config: LitellmSettings) -> None: + try: + import logging + + import litellm + except ImportError: + return + + level = logging.DEBUG if str(config.log_level).upper() == "DEBUG" else logging.WARNING + litellm.log_level = "DEBUG" if level == logging.DEBUG else "WARNING" + litellm.suppress_debug_info = level != logging.DEBUG + litellm.set_verbose = level == logging.DEBUG + + for name in ("LiteLLM", "LiteLLM Proxy", "LiteLLM Router", "litellm"): + logger = logging.getLogger(name) + logger.setLevel(level) + logger.propagate = False + + +def _configure_inference(config: LitellmSettings) -> None: + try: + import litellm + except ImportError: + return + + litellm.drop_params = config.drop_params + litellm.modify_params = config.modify_params + litellm.timeout = config.timeout diff --git a/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/health.py b/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/health.py new file mode 100644 index 00000000..a0ccc91d --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/health.py @@ -0,0 +1,51 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Minimal LiteLLM model accessibility check.""" + +from __future__ import annotations + +import logging + + +async def acheck_model_accessible(model: str) -> None: + """Raise if LiteLLM cannot access the configured model. + + Uses a minimal ``acompletion`` call instead of ``ahealth_check`` because + the latter pulls in ``litellm.proxy`` internals that require the optional + ``backoff`` package (only declared under ``litellm[proxy]``). + """ + import litellm + + await litellm.acompletion( + model=model, + messages=[{"role": "user", "content": "hi"}], + max_tokens=10, + ) + + +def check_model_accessible_sync( + model: str, + logger: logging.Logger, + timeout: float = 15.0, +) -> None: + """Synchronous wrapper for model health check. + + Args: + model: The model identifier to check + logger: Logger for info/error messages + timeout: Timeout in seconds for the health check + + Raises: + RuntimeError: If the model is not accessible + """ + from ...utils.sync import run_sync + + logger.info("Running LiteLLM model health check (model=%s)", model) + try: + run_sync(acheck_model_accessible(model), timeout=timeout) + logger.info("Model health check passed for %s", model) + except Exception as exc: + error_msg = getattr(exc, "message", "") or str(exc) or repr(exc) + logger.error("Model health check failed for %s: %s", model, error_msg) + raise RuntimeError(f"Model {model} is not accessible: {error_msg}") from exc diff --git a/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/proxy.py b/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/proxy.py new file mode 100644 index 00000000..954adcef --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/proxy.py @@ -0,0 +1,296 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Lightweight helper to launch a single-model LiteLLM proxy for the lifetime of an object. + +Usage (expects OPENAI_API_BASE/OPENAI_API_KEY already set for your backend): + from . import LitellmProxy + + with LitellmProxy(model="openai/gpt-4o-mini") as proxy: + os.environ["OPENAI_API_BASE"] = proxy.base_url + # run your Codex/Exgentic flow that expects an OpenAI-compatible endpoint +""" + +from __future__ import annotations + +import json +import os +import socket +import subprocess +import tempfile +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Optional + +from ...core.context import try_get_context +from ...core.types.model_settings import ModelSettings +from ...utils.paths import get_session_paths +from ...utils.settings import get_settings +from .trace_logger import ( + DEFAULT_FILE as DEFAULT_USAGE_FILE, +) + +TRACE_CALLBACK = "exgentic.integrations.litellm.trace_logger.trace_logger" +ASYNC_TRACE_CALLBACK = "exgentic.integrations.litellm.trace_logger.async_trace_logger" + + +def _get_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("", 0)) + return int(s.getsockname()[1]) + + +class LitellmProxy: + """Launch a LiteLLM proxy serving a single model. + + The proxy process is started on creation (or __enter__) and torn down on close/__exit__. + """ + + def __init__( + self, + model: str, + *, + port: Optional[int] = None, + model_alias_map: Optional[dict[str, str]] = None, + env: Optional[dict[str, str]] = None, + log_path: Optional[str] = None, + usage_log_path: Optional[str] = None, + startup_timeout: float = 15.0, + model_settings: ModelSettings | None = None, + ) -> None: + self.model = model + self.port = port or _get_free_port() + self.model_alias_map = model_alias_map or {} + self._env_overrides = env or {} + self._log_path = log_path + self.usage_log_path = usage_log_path + self.startup_timeout = startup_timeout + self.model_settings = model_settings or ModelSettings() + self._log_file = None + self._proc: Optional[subprocess.Popen[str]] = None + self._config_file: Optional[Path] = None + + @property + def base_url(self) -> str: + return f"http://127.0.0.1:{self.port}" + + def start(self) -> None: + if self._proc and self._proc.poll() is None: + return + + env = self._build_env() + self._set_trace_log_env(env, self._resolve_usage_log_path(), self._resolve_session_root()) + self._config_file = self._write_config_file() + config_dir = str(self._config_file.parent) + env["PYTHONPATH"] = os.pathsep.join([config_dir, env.get("PYTHONPATH", "")]) + cmd = self._build_command(self._config_file) + stdout, stderr = self._build_stdio() + + self._proc = subprocess.Popen( + cmd, + stdout=stdout, + stderr=stderr, + text=True, + env=env, + ) + + # Wait briefly for the proxy to bind; fail fast if it exits. + self._wait_for_startup(stdout=stdout) + + def _build_command(self, config_path: Path) -> list[str]: + cmd = ["litellm", "--port", str(self.port)] + cmd.extend(["--config", str(config_path)]) + return cmd + + def _build_env(self) -> dict[str, str]: + env = os.environ.copy() + env.update(self._env_overrides) + + repo_root = Path(__file__).resolve().parents[4] + src_path = repo_root / "src" + extra_paths: list[str] = [str(src_path), str(repo_root)] + + settings = get_settings() + ctx = try_get_context() + if ctx is not None: + for k, v in ctx.to_env().items(): + env.setdefault(k, v) + + existing = {key.lower() for key in env} + for key, value in settings.get_env().items(): + if key.lower() in existing: + continue + env[key] = value + + # Keep proxy cache bootstrap behavior enabled by default for subprocess startup. + env["EXGENTIC_PROXY_CACHE_INIT"] = "true" + + env["PYTHONPATH"] = os.pathsep.join([*extra_paths, env.get("PYTHONPATH", "")]) + return env + + def _resolve_usage_log_path(self) -> Path: + if self.usage_log_path: + return Path(self.usage_log_path) + if self._log_path: + return Path(self._log_path).with_name("trace.jsonl") + return Path(DEFAULT_USAGE_FILE) + + def _resolve_session_root(self) -> Path | None: + ctx = try_get_context() + if ctx is not None: + session_id = ctx.session_id + if session_id is not None: + return get_session_paths(session_id).root + return None + + @staticmethod + def _set_trace_log_env(env: dict[str, str], usage_path: Path, session_root: Optional[Path]) -> None: + from .trace_logger import FILE_ENV + + env.setdefault(FILE_ENV, str(usage_path)) + + def _build_litellm_params(self) -> dict[str, object]: + litellm_params: dict[str, object] = {"model": self.model} + if self.model_settings.temperature is not None: + litellm_params["temperature"] = self.model_settings.temperature + if self.model_settings.max_tokens is not None: + litellm_params["max_tokens"] = self.model_settings.max_tokens + if self.model_settings.top_p is not None: + litellm_params["top_p"] = self.model_settings.top_p + if self.model_settings.reasoning_effort is not None: + litellm_params["reasoning_effort"] = self.model_settings.reasoning_effort + return litellm_params + + def _build_config_data(self) -> dict[str, object]: + trace_cb = TRACE_CALLBACK + async_cb = ASYNC_TRACE_CALLBACK + config_data: dict[str, object] = { + "model_list": [ + { + "model_name": self.model, + "litellm_params": self._build_litellm_params(), + } + ], + "litellm_settings": { + "success_callback": [trace_cb, async_cb], + "failure_callback": [trace_cb, async_cb], + # Force chat/completions instead of /responses for Anthropic + # message translation — many backends (Azure proxies, etc.) + # don't expose the newer Responses API endpoint. + "use_chat_completions_url_for_anthropic_messages": True, + }, + } + router_settings: dict[str, object] = {} + if self.model_alias_map: + router_settings["model_group_alias"] = self.model_alias_map + if self.model_settings.num_retries is not None: + router_settings["num_retries"] = self.model_settings.num_retries + router_settings["retry_after"] = self.model_settings.retry_after + if router_settings: + config_data["router_settings"] = router_settings + return config_data + + def _write_config_file(self) -> Path: + if self._log_path: + cfg_path = Path(self._log_path).with_name("litellm_config.json") + else: + cfg_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".json").name) + config_data = self._build_config_data() + with open(cfg_path, "w", encoding="utf-8") as fh: + json.dump(config_data, fh) + return cfg_path + + def _build_stdio(self): + stdout = subprocess.PIPE + stderr = subprocess.PIPE + if self._log_path: + self._log_file = open(self._log_path, "w", encoding="utf-8") + stdout = self._log_file + stderr = subprocess.STDOUT + return stdout, stderr + + def _wait_for_startup(self, *, stdout) -> None: + deadline = time.time() + self.startup_timeout + last_err: Optional[str] = None + while time.time() < deadline: + if self._proc and self._proc.poll() is not None: + # Process exited; capture any output for debugging. + out, err = ("", "") + if stdout is subprocess.PIPE: + out, err = self._proc.communicate(timeout=0.5) + if self._log_path and os.path.exists(self._log_path): + with open( + self._log_path, + encoding="utf-8-sig", + errors="replace", + newline="", + ) as lf: + last_err = lf.read() + raise RuntimeError(f"LiteLLM proxy exited early: {err or out or last_err or 'no output'}") + if _is_port_open("127.0.0.1", self.port): + if _is_proxy_ready("127.0.0.1", self.port): + return + time.sleep(0.05) + + raise RuntimeError(f"LiteLLM proxy did not open port {self.port} within timeout") + + def close(self) -> None: + proc = self._proc + if proc and proc.poll() is None: + try: + proc.terminate() + proc.wait(timeout=30) + except Exception: + # fall through to kill below + pass + if proc.poll() is None: + proc.kill() + self._proc = None + if self._log_file: + self._log_file.close() + self._log_file = None + self._config_file = None + + def __enter__(self) -> LitellmProxy: + self.start() + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.close() + + +def _is_port_open(host: str, port: int) -> bool: + try: + with socket.create_connection((host, port), timeout=0.2): + return True + except Exception: + return False + + +def _probe_http_status(host: str, port: int, path: str) -> int | None: + url = f"http://{host}:{port}{path}" + req = urllib.request.Request(url, method="GET") + try: + with urllib.request.urlopen(req, timeout=0.5) as resp: + return getattr(resp, "status", 200) + except urllib.error.HTTPError as err: + return err.code + except Exception: + return None + + +def _is_proxy_ready(host: str, port: int) -> bool: + status = _probe_http_status(host, port, "/health/liveliness") + if status is not None and 200 <= status < 300: + return True + status = _probe_http_status(host, port, "/v1/models") + if status is None: + return False + if 200 <= status < 300: + return True + if status in (401, 403): + # Auth-required, but proxy is up. + return True + return False diff --git a/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/trace_cost.py b/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/trace_cost.py new file mode 100644 index 00000000..6b1fbaa4 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/trace_cost.py @@ -0,0 +1,61 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import json +from pathlib import Path + +from ...utils.cost import litellm_tokens_cost + + +def _coerce_float(value: object) -> float | None: + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + try: + return float(value) + except ValueError: + return None + return None + + +def load_trace_cost(log_path: Path | str, model_name: str) -> float: + """Sum cost from a trace JSONL log. + + Uses the explicit ``cost`` field when present; otherwise falls back to + computing cost from prompt/completion token counts. + """ + path = Path(log_path) + if not path.exists(): + return 0.0 + + total = 0.0 + try: + with path.open("r", encoding="utf-8-sig", errors="replace", newline="") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + record = json.loads(line) + except json.JSONDecodeError: + continue + + explicit = _coerce_float(record.get("cost")) + if explicit is not None: + total += explicit + continue + + prompt_tokens = record.get("prompt_tokens") or 0 + completion_tokens = record.get("completion_tokens") or 0 + computed = litellm_tokens_cost( + model_name=model_name, + input_tokens=prompt_tokens, + output_tokens=completion_tokens, + ).total_cost + total += float(computed or 0.0) + except FileNotFoundError: + return 0.0 + + return total diff --git a/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/trace_logger.py b/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/trace_logger.py new file mode 100644 index 00000000..cc993cde --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/integrations/litellm/trace_logger.py @@ -0,0 +1,670 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Custom LiteLLM logger that writes token/cost usage and request/response trace to a JSONL file. + +Environment variables: + EXGENTIC_LLM_LOG_FILE: optional override of the output JSONL path (default: trace.jsonl in CWD). + EXGENTIC_OTEL_ENABLED: enable OpenTelemetry span creation for LLM calls. +""" + +from __future__ import annotations + +import json +import os +import warnings +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +from litellm.integrations.custom_logger import CustomLogger + +from ...utils.settings import get_settings + +# Environment variable constants +FILE_ENV = "EXGENTIC_LLM_LOG_FILE" +DEFAULT_FILE = "trace.jsonl" + + +def _otel_enabled() -> bool: + """Check if OTEL is enabled via settings.""" + return bool(get_settings().otel_enabled) + + +def _otel_record_content() -> bool: + """Check if OTEL content recording is enabled via settings.""" + return get_settings().otel_record_content + + +class TraceLogger(CustomLogger): + def __init__(self, file_path: str | None = None) -> None: + super().__init__() + self._file_path = file_path + self._tracer = None + self._otel_logger = None + self._context = None + + @staticmethod + def _ensure_context() -> None: + try: + from ...core.context import init_context_from_env, try_get_context + + if try_get_context() is not None: + return + + init_context_from_env() + except RuntimeError: + pass + + def _init_otel(self, kwargs) -> None: + import threading + + from ...utils.otel import get_session_logger, init_tracing_from_env + + ctx = self.get_context(kwargs) + if ctx is None or ctx.session_id is None or ctx.otel_context is None: + # no otel context means tracing cannot be initialized + warnings.warn( + f"No OTEL context found for TraceLogger, skipping OTEL initialization. context={ctx}", + stacklevel=2, + ) + return + + # Initialize the TracerProvider (use simple processor for subprocess) + self._tracer = init_tracing_from_env() + + base = Path(ctx.output_dir) / ctx.run_id + session_root = base / "sessions" / ctx.session_id + self._otel_logger = get_session_logger( + session_root, + f"{__name__} | pid={os.getpid()} tid={threading.get_native_id()}", + ) + + def _get_parent_context(self, kwargs) -> Any: + """Reconstruct parent span context from environment variables or ContextVar. + + For proxy subprocess: reads from environment variables + For direct callback: reads from Context ContextVar + """ + from opentelemetry import context, trace + from opentelemetry.trace import NonRecordingSpan, SpanContext, TraceFlags + + ctx = self.get_context(kwargs) + + trace_id_hex = ctx.otel_context.trace_id + span_id_hex = ctx.otel_context.span_id + + if not trace_id_hex or not span_id_hex: + return context.get_current() + + trace_id = int(trace_id_hex, 16) + span_id = int(span_id_hex, 16) + + self._otel_logger.log_context_read(trace_id_hex, span_id_hex) + + span_context = SpanContext( + trace_id=trace_id, + span_id=span_id, + is_remote=True, + trace_flags=TraceFlags(0x01), + ) + + parent_span = NonRecordingSpan(span_context) + return trace.set_span_in_context(parent_span) + + def _create_llm_span(self, kwargs, name: str, start_time: Optional[Any] = None) -> Optional[Any]: + """Create span for LLM call with CLIENT span kind. + + Args: + kwargs: Keyword arguments containing context and other parameters + name: Name of the span + start_time: Optional datetime when the span started + """ + from opentelemetry.trace import SpanKind + + parent_ctx = self._get_parent_context(kwargs) + + if start_time: + # Convert datetime to nanoseconds since epoch for OTEL + start_time_ns = int(start_time.timestamp() * 1_000_000_000) + span = self._tracer.start_span(name, context=parent_ctx, start_time=start_time_ns, kind=SpanKind.CLIENT) + else: + span = self._tracer.start_span(name, context=parent_ctx, kind=SpanKind.CLIENT) + + span_ctx = span.get_span_context() + + # Extract parent span ID from context for logging + ctx = self.get_context(kwargs) + parent_span_id = ctx.otel_context.span_id if ctx and ctx.otel_context else None + + self._otel_logger.log_span_start( + span_name=name, + span_id=format(span_ctx.span_id, "016x"), + trace_id=format(span_ctx.trace_id, "032x"), + parent_span_id=parent_span_id, + start_time=start_time, + ) + + return span + + def _set_attribute(self, span: Any, key: str, value: Any) -> None: + span.set_attribute(key, value) + span_ctx = span.get_span_context() + self._otel_logger.log_attribute_set(key, value, format(span_ctx.span_id, "016x")) + + @staticmethod + def _metadata_context(kwargs: dict[str, Any]): + from ...core.context import Context, OtelContext, Role + + # Check multiple locations where metadata might be stored + # 1. Direct litellm_metadata parameter + metadata = kwargs.get("litellm_metadata") + if not metadata: + # 2. litellm_params.litellm_metadata + metadata = kwargs.get("litellm_params", {}).get("litellm_metadata") + if not metadata: + # 3. litellm_params.metadata (for 'metadata' parameter) + metadata = kwargs.get("litellm_params", {}).get("metadata") + + if not isinstance(metadata, dict): + return None + + context = metadata.get("context") + if isinstance(context, Context): + return context + + # Reconstruct Context from serialized fields + # Check if we have the required fields + if "exgentic_ctx_run_id" not in metadata: + return None + + # Reconstruct OtelContext if present + otel_context = None + if "exgentic_ctx_otel_trace_id" in metadata and "exgentic_ctx_otel_span_id" in metadata: + otel_context = OtelContext( + trace_id=metadata["exgentic_ctx_otel_trace_id"], + span_id=metadata["exgentic_ctx_otel_span_id"], + ) + + # Reconstruct Role + role_str = metadata.get("exgentic_ctx_role", "framework") + try: + role = Role(role_str) + except ValueError: + role = Role.FRAMEWORK + + # Reconstruct Context + return Context( + run_id=metadata["exgentic_ctx_run_id"], + output_dir=metadata["exgentic_ctx_output_dir"], + cache_dir=metadata["exgentic_ctx_cache_dir"], + session_id=metadata.get("exgentic_ctx_session_id"), + task_id=metadata.get("exgentic_ctx_task_id"), + role=role, + otel_context=otel_context, + ) + + @staticmethod + def _context_log_path(ctx) -> str: + base = Path(ctx.output_dir) / ctx.run_id + if ctx.session_id: + return str(base / "sessions" / ctx.session_id / ctx.role.value / "litellm" / "trace.jsonl") + return str(base / "run" / "litellm" / "trace.jsonl") + + def get_context(self, kwargs: dict[str, Any]): + from ...core.context import Context, try_get_context + + self._ensure_context() + metadata_context = self._metadata_context(kwargs) + if isinstance(metadata_context, Context): + return metadata_context + context = kwargs.get("context") + if isinstance(context, Context): + return context + return try_get_context() + + def _resolve_log_path(self, kwargs: dict[str, Any]) -> str: + from ...core.context import Context + + if self._file_path: + return self._file_path + + self._ensure_context() + metadata_context = self._metadata_context(kwargs) + if metadata_context is not None: + return self._context_log_path(metadata_context) + + context = kwargs.get("context") + if isinstance(context, Context): + return self._context_log_path(context) + + file_path = os.environ.get(FILE_ENV) + if file_path: + return file_path + + ctx = self.get_context(kwargs) # TODO: this is redundant + if ctx is not None: + return self._context_log_path(ctx) + + return DEFAULT_FILE + + def _write_row(self, kwargs: dict[str, Any], response_obj: dict[str, Any], status: str) -> None: + file_path = self._resolve_log_path(kwargs) + Path(file_path).parent.mkdir(parents=True, exist_ok=True) + usage = self._extract_usage(response_obj) + row = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "status": status, + "model": kwargs.get("model"), + "prompt_tokens": usage.get("prompt_tokens"), + "completion_tokens": usage.get("completion_tokens"), + "total_tokens": usage.get("total_tokens"), + "cost": kwargs.get("response_cost"), + "request": self._capture_request(kwargs), + "response": self._capture_response(response_obj), + "trace_id": kwargs.get("litellm_trace_id") or kwargs.get("litellm_call_id"), + } + + with open(file_path, "a", encoding="utf-8") as f: + f.write(json.dumps(row, ensure_ascii=False, default=str) + "\n") + + def _write_otel( + self, + kwargs: dict[str, Any], + response_obj: dict[str, Any], + status: str, + start_time=None, + end_time=None, + ) -> None: + """Write OTEL span for LLM call following GenAI inference span conventions. + + Follows OpenTelemetry GenAI semantic conventions for inference spans: + https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/#inference + + Span name format: {gen_ai.operation.name} {gen_ai.request.model} + Span kind: CLIENT + """ + try: + if not _otel_enabled(): + return + + # Lazy initialize OTEL if not already done + if self._tracer is None: + self._init_otel(kwargs) + ctx = self.get_context(kwargs) + + # Lazy import GenAI semantic conventions + from opentelemetry.trace import Status, StatusCode + + optional_params = kwargs.get("optional_params", {}) or {} + litellm_params = kwargs.get("litellm_params", {}) or {} + + # ===== DETERMINE OPERATION TYPE ===== + # Required attribute: gen_ai.operation.name + operation = "chat" if kwargs.get("messages") else "text_completion" + + # ===== CREATE SPAN WITH PROPER NAME ===== + # Span name format: {gen_ai.operation.name} {gen_ai.request.model} + model = kwargs.get("model", "unknown") + span_name = f"{operation} {model}" + span = self._create_llm_span(kwargs, span_name, start_time=start_time) + + # ===== SET SPAN STATUS ===== + if status == "success": + span.set_status(Status(StatusCode.OK)) + else: + span.set_status(Status(StatusCode.ERROR)) + # Conditionally Required: error.type if operation ended in error + error_info = kwargs.get("exception") or response_obj.get("error") + if error_info: + if isinstance(error_info, dict): + error_type = error_info.get("type") or error_info.get("code") or "unknown_error" + elif isinstance(error_info, Exception): + error_type = type(error_info).__name__ + else: + error_type = str(error_info) + self._set_attribute(span, "error.type", error_type) + + # ===== REQUIRED ATTRIBUTES ===== + # gen_ai.operation.name (Required) + self._set_attribute(span, "gen_ai.operation.name", operation) + + # gen_ai.provider.name (Required) - maps LiteLLM provider to standard names + provider = litellm_params.get("custom_llm_provider", "unknown") + if provider is None: + provider = "unknown" + # Map common LiteLLM providers to standard GenAI provider names + provider_mapping = { + "openai": "openai", + "azure": "azure.ai.openai", + "anthropic": "anthropic", + "bedrock": "aws.bedrock", + "vertex_ai": "gcp.vertex_ai", + "gemini": "gcp.gemini", + "cohere": "cohere", + "groq": "groq", + "mistral": "mistral_ai", + "deepseek": "deepseek", + "perplexity": "perplexity", + "watsonx": "ibm.watsonx.ai", + "xai": "x_ai", + } + standard_provider = provider_mapping.get(provider.lower(), provider) + self._set_attribute(span, "gen_ai.provider.name", standard_provider) + + # ===== CONDITIONALLY REQUIRED ATTRIBUTES ===== + # gen_ai.request.model (Conditionally Required if available) + if model: + self._set_attribute(span, "gen_ai.request.model", model) + + # gen_ai.request.choice.count (Conditionally Required if available and !=1) + n = optional_params.get("n") + if n is not None and n != 1: + self._set_attribute(span, "gen_ai.request.choice.count", n) + + # gen_ai.request.seed (Conditionally Required if applicable and request includes seed) + seed = optional_params.get("seed") + if seed is not None: + self._set_attribute(span, "gen_ai.request.seed", seed) + + # server.port (Conditionally Required if server.address is set) + # Note: LiteLLM doesn't typically expose server details, skip for now + + # ===== RECOMMENDED REQUEST ATTRIBUTES ===== + self._set_attribute(span, "gen_ai.conversation.id", ctx.session_id) + + if optional_params.get("max_tokens") is not None: + self._set_attribute(span, "gen_ai.request.max_tokens", optional_params["max_tokens"]) + + if optional_params.get("temperature") is not None: + self._set_attribute(span, "gen_ai.request.temperature", optional_params["temperature"]) + + if optional_params.get("top_p") is not None: + self._set_attribute(span, "gen_ai.request.top_p", optional_params["top_p"]) + + if optional_params.get("top_k") is not None: + self._set_attribute(span, "gen_ai.request.top_k", float(optional_params["top_k"])) + + if optional_params.get("frequency_penalty") is not None: + self._set_attribute( + span, + "gen_ai.request.frequency_penalty", + optional_params["frequency_penalty"], + ) + + if optional_params.get("presence_penalty") is not None: + self._set_attribute( + span, + "gen_ai.request.presence_penalty", + optional_params["presence_penalty"], + ) + + # gen_ai.request.stop_sequences (Recommended) + stop = optional_params.get("stop") + if stop is not None: + if isinstance(stop, list): + self._set_attribute(span, "gen_ai.request.stop_sequences", stop) + else: + self._set_attribute(span, "gen_ai.request.stop_sequences", [stop]) + + # ===== RECOMMENDED RESPONSE ATTRIBUTES ===== + if response_obj: + # Helper function to safely get attribute from dict or object + def safe_get(obj, key, default=None): + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + + # Get span context for logging + span_ctx = span.get_span_context() + span_id_hex = format(span_ctx.span_id, "016x") + + # gen_ai.response.id (Recommended) + response_id = safe_get(response_obj, "id") + if response_id: + self._set_attribute(span, "gen_ai.response.id", response_id) + if self._otel_logger: + self._otel_logger.log_attribute_set("gen_ai.response.id", response_id, span_id_hex) + + # gen_ai.response.model (Recommended) + response_model = safe_get(response_obj, "model") + if response_model: + self._set_attribute(span, "gen_ai.response.model", response_model) + if self._otel_logger: + self._otel_logger.log_attribute_set("gen_ai.response.model", response_model, span_id_hex) + + # gen_ai.usage.input_tokens and gen_ai.usage.output_tokens (Recommended) + usage = safe_get(response_obj, "usage") + if usage: + prompt_tokens = safe_get(usage, "prompt_tokens") + if prompt_tokens is not None: + self._set_attribute(span, "gen_ai.usage.input_tokens", prompt_tokens) + if self._otel_logger: + self._otel_logger.log_attribute_set("gen_ai.usage.input_tokens", prompt_tokens, span_id_hex) + + completion_tokens = safe_get(usage, "completion_tokens") + if completion_tokens is not None: + self._set_attribute(span, "gen_ai.usage.output_tokens", completion_tokens) + if self._otel_logger: + self._otel_logger.log_attribute_set( + "gen_ai.usage.output_tokens", + completion_tokens, + span_id_hex, + ) + + # gen_ai.response.finish_reasons (Recommended) + choices = safe_get(response_obj, "choices", []) + if choices: + finish_reasons = [] + for choice in choices: + finish_reason = safe_get(choice, "finish_reason") + if finish_reason: + finish_reasons.append(finish_reason) + if finish_reasons: + self._set_attribute(span, "gen_ai.response.finish_reasons", finish_reasons) + if self._otel_logger: + self._otel_logger.log_attribute_set( + "gen_ai.response.finish_reasons", + finish_reasons, + span_id_hex, + ) + + # ===== OPT-IN ATTRIBUTES (for content recording) ===== + # Note: These are opt-in and may contain sensitive data + # Only include if explicitly enabled via settings + if _otel_record_content(): + # gen_ai.tool.definitions (Opt-In) + tools = kwargs.get("tools") + if tools: + try: + self._set_attribute( + span, + "gen_ai.tool.definitions", + json.dumps(tools, default=str), + ) + except Exception: + pass # Skip if serialization fails + + # gen_ai.input.messages (Opt-In) - structured format + messages = kwargs.get("messages") + if messages: + # Convert to GenAI message format + try: + self._set_attribute( + span, + "gen_ai.input.messages", + json.dumps(messages, default=str), + ) + except Exception: + pass # Skip if serialization fails + + # gen_ai.output.messages (Opt-In) - structured format + if response_obj: + # Helper function already defined above in the response attributes section + def safe_get(obj, key, default=None): + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + + choices = safe_get(response_obj, "choices", []) + if choices: + output_messages = [] + for choice in choices: + message = safe_get(choice, "message") + if message: + output_msg = { + "role": safe_get(message, "role", "assistant"), + "parts": [], + } + content = safe_get(message, "content") + if content: + output_msg["parts"].append({"type": "text", "content": content}) + # Include tool calls if present + tool_calls = safe_get(message, "tool_calls") + if tool_calls: + for tc in tool_calls: + func = safe_get(tc, "function", {}) + output_msg["parts"].append( + { + "type": "tool_call", + "id": safe_get(tc, "id"), + "name": safe_get(func, "name"), + "arguments": safe_get(func, "arguments"), + } + ) + finish_reason = safe_get(choice, "finish_reason") + if finish_reason: + output_msg["finish_reason"] = finish_reason + output_messages.append(output_msg) + + if output_messages: + try: + self._set_attribute( + span, + "gen_ai.output.messages", + json.dumps(output_messages, default=str), + ) + except Exception: + pass # Skip if serialization fails + + # ===== END SPAN ===== + if end_time: + end_time_ns = int(end_time.timestamp() * 1_000_000_000) + span.end(end_time=end_time_ns) + else: + span.end() + + span_ctx = span.get_span_context() + if self._otel_logger: + self._otel_logger.log_span_end( + span_name=span_name, + span_id=format(span_ctx.span_id, "016x"), + status=status, + end_time=end_time, + ) + except Exception: + pass + + def log_success_event(self, kwargs: dict[str, Any], response_obj: dict[str, Any], start_time, end_time): + self._write_row(kwargs, response_obj, status="success") + self._write_otel( + kwargs, + response_obj, + status="success", + start_time=start_time, + end_time=end_time, + ) + + async def async_log_success_event(self, kwargs: dict[str, Any], response_obj: dict[str, Any], start_time, end_time): + self._write_row(kwargs, response_obj, status="success") + self._write_otel( + kwargs, + response_obj, + status="success", + start_time=start_time, + end_time=end_time, + ) + + def log_failure_event(self, kwargs: dict[str, Any], response_obj: dict[str, Any], start_time, end_time): + self._write_row(kwargs, response_obj, status="failure") + self._write_otel( + kwargs, + response_obj, + status="failure", + start_time=start_time, + end_time=end_time, + ) + + async def async_log_failure_event(self, kwargs: dict[str, Any], response_obj: dict[str, Any], start_time, end_time): + self._write_row(kwargs, response_obj, status="failure") + self._write_otel( + kwargs, + response_obj, + status="failure", + start_time=start_time, + end_time=end_time, + ) + + async def async_log_stream_event(self, kwargs: dict[str, Any], response_obj: dict[str, Any], start_time, end_time): + self._write_row(kwargs, response_obj, status="stream") + self._write_otel(kwargs, response_obj, status="stream") + + # Helpers ----------------------------------------------------- + + def _extract_usage(self, response_obj: Any) -> dict[str, Any]: + usage: dict[str, Any] = {} + if isinstance(response_obj, dict): + usage = response_obj.get("usage", {}) or {} + else: + usage_attr = getattr(response_obj, "usage", None) + if isinstance(usage_attr, dict): + usage = usage_attr + elif hasattr(usage_attr, "__dict__"): + usage = usage_attr.__dict__ + return usage + + def _capture_request(self, kwargs: dict[str, Any]) -> dict[str, Any]: + safe: dict[str, Any] = {} + for key in ( + "messages", + "prompt", + "tools", + "tool_choice", + "functions", + "function_call", + "temperature", + "model", + ): + if key in kwargs: + safe[key] = kwargs[key] + return safe + + def _capture_response(self, response_obj: Any) -> dict[str, Any]: + if isinstance(response_obj, dict): + resp = dict(response_obj) + resp.pop("usage", None) + return resp + out: dict[str, Any] = {} + for attr in ("choices", "id", "object", "created", "model", "error"): + if hasattr(response_obj, attr): + out[attr] = getattr(response_obj, attr) + if not out: + out["repr"] = repr(response_obj) + return out + + +# Sync logger for direct SDK calls (e.g. tool-calling agent). +class SyncTraceLogger(TraceLogger): + def __call__(self, *args, **kwargs): + return None + + +class AsyncTraceLogger(TraceLogger): + async def __call__(self, *args, **kwargs): + return None + + +# Expose module-level instances. +trace_logger = TraceLogger() +sync_trace_logger = SyncTraceLogger() +async_trace_logger = AsyncTraceLogger() diff --git a/labs/AgentStream/exgentic/src/exgentic/interfaces/__init__.py b/labs/AgentStream/exgentic/src/exgentic/interfaces/__init__.py new file mode 100644 index 00000000..ed18cc54 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/interfaces/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +__all__ = [] diff --git a/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/__init__.py b/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/__init__.py new file mode 100644 index 00000000..23d5c48c --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""CLI entrypoints for Exgentic.""" diff --git a/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/commands/__init__.py b/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/commands/__init__.py new file mode 100644 index 00000000..ceb9e333 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/commands/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""CLI command modules.""" diff --git a/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/commands/analyze.py b/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/commands/analyze.py new file mode 100644 index 00000000..55b809a8 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/commands/analyze.py @@ -0,0 +1,1223 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import rich_click as click + +plt = None +np = None +pd = None + + +def _ensure_analysis_deps() -> None: + global plt, np, pd + if plt is None or np is None or pd is None: + try: + import matplotlib.pyplot as _plt + import numpy as _np + import pandas as _pd + except ImportError as exc: + raise click.ClickException( + "Analysis commands require the optional analysis dependencies. " + "Install them with `pip install 'exgentic[analysis]'`." + ) from exc + + plt = _plt + np = _np + pd = _pd + + +BENCHMARK_MAP = { + "appworld_test_normal": "AppWorld", + "browsecompplus": "BrowseComp+", + "swebench": "SWE-bench", + "tau2_airline": "TauBench-Airline", + "tau2_retail": "TauBench-Retail", + "tau2_telecom": "TauBench-Telecom", +} + +AGENT_MAP = { + "claude_code": "claude-code", + "openai_solo": "openai-mcp", + "smolagents_code": "smolagents", + "tool_calling": "litellm-react", + "tool_calling_with_shortlisting": "litellm-shortlist", +} + +MODEL_MAP = { + "openai_aws_claude-opus-4-5": "claude-opus-4.5", + "openai_Azure_gpt-5.2-2025-12-11": "gpt-5.2", + "openai_gcp_gemini-3-pro-preview": "gemini-3-pro", +} + +AGENT_SHAPES = { + "claude-code": "*", + "litellm-react": "o", + "litellm-shortlist": "s", + "openai-mcp": "D", + "smolagents": "^", +} + +MODEL_COLORS = { + "claude-opus-4.5": "#E69F00", + "gemini-3-pro": "#56B4E9", + "gpt-5.2": "#009E73", +} + + +def _project_root() -> Path: + return Path(__file__).resolve().parents[5] + + +def _coerce_numeric(df: pd.DataFrame, column: str) -> pd.Series: + return pd.to_numeric(df[column], errors="coerce") + + +def _normalize_results_df(df: pd.DataFrame) -> pd.DataFrame: + out = df.copy() + + if "benchmark" in out.columns: + out["benchmark"] = out["benchmark"].map(BENCHMARK_MAP).fillna(out["benchmark"]) + else: + raise click.ClickException("CSV missing required column: benchmark") + + if "agent" in out.columns: + out["agent"] = out["agent"].map(AGENT_MAP).fillna(out["agent"]) + elif "agent_normalized" in out.columns: + out["agent"] = out["agent_normalized"] + else: + raise click.ClickException("CSV missing required column: agent") + + if "model" in out.columns: + out["model"] = out["model"].map(MODEL_MAP).fillna(out["model"]) + elif "model_normalized" in out.columns: + out["model"] = out["model_normalized"] + else: + raise click.ClickException("CSV missing required column: model") + + if "agent_normalized" not in out.columns: + out["agent_normalized"] = out["agent"] + if "model_normalized" not in out.columns: + out["model_normalized"] = out["model"] + + if "score" in out.columns: + out["score"] = _coerce_numeric(out, "score") + elif "benchmark_score" in out.columns: + out["score"] = _coerce_numeric(out, "benchmark_score") + else: + raise click.ClickException("CSV missing benchmark_score (average_score is not allowed).") + + if "avg_steps" not in out.columns and "average_steps" in out.columns: + out["avg_steps"] = _coerce_numeric(out, "average_steps") + + if "num_tasks" not in out.columns: + if "total_sessions" in out.columns: + out["num_tasks"] = _coerce_numeric(out, "total_sessions") + elif "planned_sessions" in out.columns: + out["num_tasks"] = _coerce_numeric(out, "planned_sessions") + + if "avg_cost" not in out.columns: + if "average_run_cost" in out.columns: + out["avg_cost"] = _coerce_numeric(out, "average_run_cost") + elif "average_agent_cost" in out.columns: + out["avg_cost"] = _coerce_numeric(out, "average_agent_cost") + elif "total_run_cost" in out.columns and "total_sessions" in out.columns: + total_cost = _coerce_numeric(out, "total_run_cost") + total_sessions = _coerce_numeric(out, "total_sessions") + out["avg_cost"] = total_cost / total_sessions.replace(0, np.nan) + + if "total_cost" not in out.columns and "total_run_cost" in out.columns: + out["total_cost"] = _coerce_numeric(out, "total_run_cost") + + if "finished_pct" not in out.columns and "percent_finished" in out.columns: + out["finished_pct"] = _coerce_numeric(out, "percent_finished") + + return out + + +def _get_benchmark_weight(benchmark: str) -> float: + if "TauBench" in benchmark: + return 1.0 / 3.0 + return 1.0 + + +def _load_normalized_frames(csv_path: Path) -> tuple[pd.DataFrame, pd.DataFrame]: + df = pd.read_csv(csv_path) + df = _normalize_results_df(df) + valid_df = df[df["score"].notna()].copy() + if valid_df.empty: + raise click.ClickException("No valid scores found in CSV.") + valid_df["benchmark_weight"] = valid_df["benchmark"].apply(_get_benchmark_weight) + return df, valid_df + + +def _compute_weighted_scores(df: pd.DataFrame) -> dict[tuple[str, str], float]: + config_scores: dict[tuple[str, str], float] = {} + for (agent, model), group in df.groupby(["agent_normalized", "model_normalized"]): + scores = [] + weights = [] + for _, row in group.iterrows(): + if pd.notna(row["score"]): + scores.append(row["score"]) + weights.append(_get_benchmark_weight(row["benchmark"])) + if scores: + config_scores[(agent, model)] = float(np.average(scores, weights=weights)) + return config_scores + + +def _compute_pareto_frontier(points: list[tuple[float, float]]) -> list[tuple[float, float]]: + pareto: list[tuple[float, float]] = [] + for i, (x1, y1) in enumerate(points): + is_pareto = True + for j, (x2, y2) in enumerate(points): + if i == j: + continue + if x2 <= x1 and y2 >= y1 and (x2 < x1 or y2 > y1): + is_pareto = False + break + if is_pareto: + pareto.append((x1, y1)) + pareto.sort(key=lambda p: p[0]) + return pareto + + +def _build_leaderboard_table(df: pd.DataFrame) -> tuple[pd.DataFrame, list[str]]: + valid_df = df[df["score"].notna()].copy() + valid_df["benchmark_weight"] = valid_df["benchmark"].apply(_get_benchmark_weight) + + pivot_scores = valid_df.pivot_table( + index=["agent_normalized", "model_normalized"], + columns="benchmark", + values="score", + aggfunc="mean", + ) + + config_metrics = [] + for (agent, model), group in valid_df.groupby(["agent_normalized", "model_normalized"]): + mean_score = np.average(group["score"], weights=group["benchmark_weight"]) + + if "avg_steps" in group.columns and group["avg_steps"].notna().any(): + mean_steps = np.average( + group[group["avg_steps"].notna()]["avg_steps"], + weights=group[group["avg_steps"].notna()]["benchmark_weight"], + ) + else: + mean_steps = np.nan + + if "avg_cost" in group.columns and group["avg_cost"].notna().any(): + mean_cost = np.average( + group[group["avg_cost"].notna()]["avg_cost"], + weights=group[group["avg_cost"].notna()]["benchmark_weight"], + ) + else: + mean_cost = np.nan + + config_metrics.append( + { + "agent": agent, + "model": model, + "mean_score": mean_score, + "mean_steps": mean_steps, + "mean_cost": mean_cost, + } + ) + + config_df = pd.DataFrame(config_metrics) + + config_points = {} + config_weighted_comparisons = {} + for _bench, group in valid_df.groupby("benchmark"): + weight = group["benchmark_weight"].iloc[0] + config_scores = {} + for _, row in group.iterrows(): + config = (row["agent_normalized"], row["model_normalized"]) + score = row["score"] + if pd.notna(score): + config_scores[config] = score + configs = list(config_scores.keys()) + for i, c1 in enumerate(configs): + for c2 in configs[i + 1 :]: + s1 = config_scores[c1] + s2 = config_scores[c2] + if c1 not in config_points: + config_points[c1] = 0.0 + config_weighted_comparisons[c1] = 0.0 + if c2 not in config_points: + config_points[c2] = 0.0 + config_weighted_comparisons[c2] = 0.0 + if s1 > s2: + config_points[c1] += 1.0 * weight + elif s1 == s2: + config_points[c1] += 0.5 * weight + config_points[c2] += 0.5 * weight + else: + config_points[c2] += 1.0 * weight + config_weighted_comparisons[c1] += weight + config_weighted_comparisons[c2] += weight + + win_rates = {} + for config in config_points: + if config_weighted_comparisons[config] > 0: + win_rates[config] = config_points[config] / config_weighted_comparisons[config] + else: + win_rates[config] = np.nan + + config_df["win_rate"] = config_df.apply(lambda row: win_rates.get((row["agent"], row["model"]), np.nan), axis=1) + + pivot_scores_reset = pivot_scores.reset_index() + full_table = config_df.merge( + pivot_scores_reset, + left_on=["agent", "model"], + right_on=["agent_normalized", "model_normalized"], + how="left", + ).sort_values("mean_score", ascending=False) + + benchmarks = list(pivot_scores.columns) + return full_table, benchmarks + + +def _generate_leaderboard(df: pd.DataFrame) -> str: + full_table, benchmarks = _build_leaderboard_table(df) + benchmark_short_names = { + "SWE-bench": "SWE", + "BrowseComp+": "Browse", + "TauBench-Airline": "Airline", + "TauBench-Retail": "Retail", + "TauBench-Telecom": "Telecom", + "AppWorld": "App", + } + bench_display = [benchmark_short_names.get(b, b) for b in benchmarks] + + agent_short = { + "litellm-react": "React", + "litellm-shortlist": "React+Short", + "smolagents": "Smol", + "openai-mcp": "OpenAI-MCP", + "claude-code": "Claude-Code", + } + model_short = { + "gpt-5.2": "GPT-5.2", + "claude-opus-4.5": "Opus-4.5", + "gemini-3-pro": "Gemini-3", + } + + latex = [] + latex.append(r"\begin{table*}[t]") + latex.append(r"\centering") + latex.append(r"\small") + latex.append(r"\caption{Agent-Model Configuration Leaderboard}") + latex.append(r"\label{tab:leaderboard}") + + num_benchmarks = len(benchmarks) + col_spec = "ll" + "c" * num_benchmarks + "cccc" + latex.append(f"\\begin{{tabular}}{{{col_spec}}}") + latex.append(r"\toprule") + + header1 = r"\textbf{Agent} & \textbf{Model}" + for bench_name in bench_display: + header1 += f" & \\textbf{{{bench_name}}}" + header1 += r" & \textbf{Mean} & \textbf{Win} & \textbf{Steps} & \textbf{Cost} \\" + latex.append(header1) + + header2 = r" & " + for _ in benchmarks: + header2 += " & " + header2 += r" & Score & Rate & (avg) & (\$) \\" + latex.append(header2) + latex.append(r"\midrule") + + for _, row in full_table.iterrows(): + agent_name = agent_short.get(row["agent"], row["agent"]) + model_name = model_short.get(row["model"], row["model"]) + row_str = f"{agent_name} & {model_name}" + for bench in benchmarks: + score = row.get(bench, np.nan) + if pd.notna(score): + row_str += f" & {score:.2f}" + else: + row_str += " & --" + if pd.notna(row["mean_score"]): + row_str += f" & {row['mean_score']:.2f}" + else: + row_str += " & --" + if pd.notna(row["win_rate"]): + row_str += f" & {row['win_rate']:.2f}" + else: + row_str += " & --" + if pd.notna(row["mean_steps"]): + row_str += f" & {row['mean_steps']:.1f}" + else: + row_str += " & --" + if pd.notna(row["mean_cost"]): + row_str += f" & {row['mean_cost']:.2f}" + else: + row_str += " & --" + row_str += r" \\" + latex.append(row_str) + + latex.append(r"\bottomrule") + latex.append(r"\end{tabular}") + latex.append(r"\end{table*}") + + return "\n".join(latex) + + +@click.group("analyse") +def analyse_cmd() -> None: + """Analyze result CSVs without intermediate files.""" + return + + +@analyse_cmd.command("leaderboard") +@click.argument("csv_path", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +def analyse_leaderboard_cmd(csv_path: Path) -> None: + """Generate leaderboard table from a results CSV.""" + _ensure_analysis_deps() + df = pd.read_csv(csv_path) + df = _normalize_results_df(df) + click.echo(_generate_leaderboard(df)) + + +@analyse_cmd.command("leaderboard-paper") +@click.argument("csv_path", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.option( + "--output", + "output_path", + type=click.Path(dir_okay=False, path_type=Path), + default=None, + help="Output path for the paper-style leaderboard table.", +) +def analyse_leaderboard_paper_cmd(csv_path: Path, output_path: Path | None) -> None: + """Generate the paper-style leaderboard table and save to file.""" + _ensure_analysis_deps() + df = pd.read_csv(csv_path) + df = _normalize_results_df(df) + full_table, benchmarks = _build_leaderboard_table(df) + + bench_order = [ + "AppWorld", + "BrowseComp+", + "SWE-bench", + "TauBench-Airline", + "TauBench-Retail", + "TauBench-Telecom", + ] + bench_map = {b: b for b in benchmarks} + ordered_benchmarks = [b for b in bench_order if b in bench_map] + + agent_macro = { + "openai-mcp": r"\solo{}", + "smolagents": r"\smol{}", + "litellm-react": r"\react{}", + "litellm-shortlist": r"\short{}", + "claude-code": r"\cc{}", + } + model_macro = { + "claude-opus-4.5": r"\opus{}", + "gemini-3-pro": r"\gemini{}", + "gpt-5.2": r"\gpt{}", + } + + header = r"""\definecolor{tableheader}{RGB}{248, 249, 250} +\definecolor{rowgray}{RGB}{252, 252, 253} +\definecolor{benchmarkbg}{RGB}{245, 245, 247} +% TABLE CODE (put this where you want the table): +\begin{table}[t!] +\centering +\begin{tcolorbox}[ + colback=white, + colframe=gray!20, + boxrule=0.5pt, + arc=3pt, + outer arc=3pt, + width=\columnwidth, + top=0pt, + bottom=0pt, + left=-1pt, + right=1.5pt, + boxsep=0pt +] +\resizebox{0.8\textwidth}{!}{% +\renewcommand{\arraystretch}{1.7} +\setlength{\tabcolsep}{3pt} +\footnotesize +\begin{tabular}{@{}l l c c c !{\color{gray!20}\vrule} >{\columncolor{benchmarkbg}}c """ + r""">{\columncolor{benchmarkbg}}c >{\columncolor{benchmarkbg}}c >{\columncolor{benchmarkbg}}c """ + r""">{\columncolor{benchmarkbg}}c >{\columncolor{benchmarkbg}}c@{}} +\rowcolor{tableheader} +\textbf{\#} & \textbf{\shortstack{General Agent}} & \scriptsize\textbf{Model} & """ + r"""\shortstack{\scriptsize{Avg}\\\textbf{Success}} & \shortstack{\scriptsize{Avg}\\\textbf{Cost}} & """ + r"""\cellcolor{tableheader}\tiny\textbf{\shortstack{App\\World}} & """ + r"""\cellcolor{tableheader}\tiny\textbf{\shortstack{Browse\\Comp+}} & """ + r"""\cellcolor{tableheader}\tiny\textbf{\shortstack{SWE\\benchV}} & """ + r"""\cellcolor{tableheader}\tiny\textbf{\shortstack{Tau 2\\Airline}} & """ + r"""\cellcolor{tableheader}\tiny\textbf{\shortstack{Tau 2\\Retail}} & """ + r"""\cellcolor{tableheader}\tiny\textbf{\shortstack{Tau 2\\Telecom}} \\ +""" + + def fmt_score(value: float | int | None) -> str: + if value is None or pd.isna(value): + return "--" + text = f"{value:.2f}" + if text.startswith("0"): + text = text[1:] + return text + + def fmt_cost(value: float | int | None) -> str: + if value is None or pd.isna(value): + return "--" + return f"${value:.1f}" + + rows = [] + for idx, (_, row) in enumerate(full_table.iterrows(), start=1): + agent = row["agent"] + model = row["model"] + mean_score = row["mean_score"] + mean_cost = row["mean_cost"] + agent_cell = agent_macro.get(agent, agent) + model_cell = model_macro.get(model, model) + prefix = r"\rowcolor{rowgray}" if idx % 2 == 0 else "" + cells = [ + str(idx), + agent_cell, + rf"{{\scriptsize {model_cell}}}", + fmt_score(mean_score), + fmt_cost(mean_cost), + ] + for bench in ordered_benchmarks: + score = row.get(bench, np.nan) + score_cell = f"{{\\scriptsize {fmt_score(score)}}}" if pd.notna(score) else "{\\scriptsize --}" + if prefix: + score_cell = rf"\cellcolor{{rowgray}}{score_cell}" + cells.append(score_cell) + row_line = prefix + " " + " & ".join(cells) + r" \\" + rows.append(row_line) + + footer = r""" +\end{tabular}% +} +\end{tcolorbox} +\caption{The \leaderboard{} comparing emerging general agents across standardized benchmarks. +Average Success represents the mean success rate across benchmarks; Average Cost represents the mean cost per task. +Performance is strongly influenced by backbone model choice.} +\label{tab:leaderboard} +\end{table} +""" + + latex = header + "\n".join(rows) + footer + if output_path is None: + output_path = _project_root() / "misc" / "paper" / "tables" / "leaderboard_paper.tex" + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(latex, encoding="utf-8") + click.echo(f"Saved to {output_path}") + + +@analyse_cmd.command("model-agent") +@click.argument("csv_path", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +def analyse_model_agent_cmd(csv_path: Path) -> None: + """Model vs agent variance, pair means, and interaction analysis.""" + _ensure_analysis_deps() + _, valid_df = _load_normalized_frames(csv_path) + + def weighted_mean(group): + return np.average(group["score"], weights=group["benchmark_weight"]) + + def weighted_std(group): + wmean = np.average(group["score"], weights=group["benchmark_weight"]) + variance = np.average((group["score"] - wmean) ** 2, weights=group["benchmark_weight"]) + return np.sqrt(variance) + + agent_means = valid_df.groupby("agent_normalized").apply(weighted_mean) + model_means = valid_df.groupby("model_normalized").apply(weighted_mean) + valid_df["agent_mean"] = valid_df["agent_normalized"].map(agent_means) + valid_df["model_mean"] = valid_df["model_normalized"].map(model_means) + + grand_mean = np.average(valid_df["score"], weights=valid_df["benchmark_weight"]) + total_var = np.average((valid_df["score"] - grand_mean) ** 2, weights=valid_df["benchmark_weight"]) + agent_var = np.average((valid_df["agent_mean"] - grand_mean) ** 2, weights=valid_df["benchmark_weight"]) + model_var = np.average((valid_df["model_mean"] - grand_mean) ** 2, weights=valid_df["benchmark_weight"]) + + click.echo("MODEL VS AGENT (benchmark-weighted)") + click.echo(f"Total variance: {total_var:.4f}") + click.echo(f"Agent variance: {agent_var:.4f} ({100*agent_var/total_var:.1f}%)") + click.echo(f"Model variance: {model_var:.4f} ({100*model_var/total_var:.1f}%)") + + click.echo("\nBy Model (weighted):") + by_model = ( + valid_df.groupby("model_normalized") + .apply(lambda g: pd.Series({"mean": weighted_mean(g), "std": weighted_std(g), "count": len(g)})) + .round(3) + ) + click.echo(by_model.to_string()) + + click.echo("\nBy Agent (weighted):") + by_agent = ( + valid_df.groupby("agent_normalized") + .apply(lambda g: pd.Series({"mean": weighted_mean(g), "std": weighted_std(g), "count": len(g)})) + .round(3) + ) + click.echo(by_agent.to_string()) + + click.echo("\nAgent-Model pair weighted means:") + pair_means = valid_df.groupby(["agent_normalized", "model_normalized"]).apply(weighted_mean).round(3) + for (agent, model), score in pair_means.items(): + click.echo(f"{agent:<20} {model:<20} {score:>10.3f}") + + click.echo("\nInteraction analysis (cell means):") + cell_means = valid_df.groupby(["model_normalized", "agent_normalized"])["score"].mean() + df_cells = cell_means.reset_index() + grand_mean = df_cells["score"].mean() + model_effects = df_cells.groupby("model_normalized")["score"].mean() - grand_mean + agent_effects = df_cells.groupby("agent_normalized")["score"].mean() - grand_mean + + interactions = [] + for _, row in df_cells.iterrows(): + m = row["model_normalized"] + a = row["agent_normalized"] + score = row["score"] + pred = grand_mean + model_effects[m] + agent_effects[a] + interactions.append(score - pred) + + var_model = model_effects.var() + var_agent = agent_effects.var() + var_interact = np.var(interactions) + total_comp = var_model + var_agent + var_interact + click.echo(f"Model Main Effect: {100*var_model/total_comp:.1f}%") + click.echo(f"Agent Main Effect: {100*var_agent/total_comp:.1f}%") + click.echo(f"Interaction Effect:{100*var_interact/total_comp:.1f}%") + + +@analyse_cmd.command("model-win-rate") +@click.argument("csv_path", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +def analyse_model_win_rate_cmd(csv_path: Path) -> None: + """Model win rate (TauBench weighted).""" + _ensure_analysis_deps() + _, valid_df = _load_normalized_frames(csv_path) + model_points = {m: 0.0 for m in valid_df["model_normalized"].unique()} + model_weighted_comparisons = {m: 0.0 for m in model_points} + + for (_agent, _bench), group in valid_df.groupby(["agent_normalized", "benchmark"]): + weight = group["benchmark_weight"].iloc[0] + model_scores = {} + for _, row in group.iterrows(): + model = row["model_normalized"] + score = row["score"] + if pd.notna(score): + model_scores[model] = score + models = list(model_scores.keys()) + for i, m1 in enumerate(models): + for m2 in models[i + 1 :]: + s1 = model_scores[m1] + s2 = model_scores[m2] + if s1 > s2: + model_points[m1] += 1.0 * weight + elif s1 == s2: + model_points[m1] += 0.5 * weight + model_points[m2] += 0.5 * weight + else: + model_points[m2] += 1.0 * weight + model_weighted_comparisons[m1] += weight + model_weighted_comparisons[m2] += weight + + click.echo(f"{'Model':<20} {'Win Rate':>10} {'Weighted Comparisons':>20}") + click.echo("-" * 55) + for model in sorted(model_points.keys()): + if model_weighted_comparisons[model] > 0: + win_rate = model_points[model] / model_weighted_comparisons[model] + click.echo(f"{model:<20} {100*win_rate:>9.1f}% {model_weighted_comparisons[model]:>20.1f}") + + +@analyse_cmd.command("config-win-rate") +@click.argument("csv_path", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +def analyse_config_win_rate_cmd(csv_path: Path) -> None: + """Top configuration win rates (TauBench weighted).""" + _ensure_analysis_deps() + _, valid_df = _load_normalized_frames(csv_path) + config_points = {} + config_weighted_comparisons = {} + + for _bench, group in valid_df.groupby("benchmark"): + weight = group["benchmark_weight"].iloc[0] + config_scores = {} + for _, row in group.iterrows(): + config = (row["agent_normalized"], row["model_normalized"]) + score = row["score"] + if pd.notna(score): + config_scores[config] = score + configs = list(config_scores.keys()) + for i, c1 in enumerate(configs): + for c2 in configs[i + 1 :]: + s1 = config_scores[c1] + s2 = config_scores[c2] + if c1 not in config_points: + config_points[c1] = 0.0 + config_weighted_comparisons[c1] = 0.0 + if c2 not in config_points: + config_points[c2] = 0.0 + config_weighted_comparisons[c2] = 0.0 + if s1 > s2: + config_points[c1] += 1.0 * weight + elif s1 == s2: + config_points[c1] += 0.5 * weight + config_points[c2] += 0.5 * weight + else: + config_points[c2] += 1.0 * weight + config_weighted_comparisons[c1] += weight + config_weighted_comparisons[c2] += weight + + config_win_rates = [] + for config in config_points: + if config_weighted_comparisons[config] > 0: + win_rate = config_points[config] / config_weighted_comparisons[config] + config_win_rates.append((config, win_rate, config_weighted_comparisons[config])) + config_win_rates.sort(key=lambda x: x[1], reverse=True) + + click.echo(f"{'Agent':<20} {'Model':<20} {'Win Rate':>10} {'Weighted Comps':>15}") + click.echo("-" * 70) + for (agent, model), win_rate, comps in config_win_rates[:10]: + click.echo(f"{agent:<20} {model:<20} {100*win_rate:>9.1f}% {comps:>15.1f}") + + +@analyse_cmd.command("best-per-benchmark") +@click.argument("csv_path", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +def analyse_best_per_benchmark_cmd(csv_path: Path) -> None: + """Best configuration per benchmark (top 3).""" + _ensure_analysis_deps() + _, valid_df = _load_normalized_frames(csv_path) + for bench in sorted(valid_df["benchmark"].unique()): + bench_df = valid_df[valid_df["benchmark"] == bench] + best = bench_df.loc[bench_df["score"].idxmax()] + click.echo(f"\n{bench}:") + click.echo(f" Winner: {best['agent_normalized']} + {best['model_normalized']}") + click.echo(f" Score: {best['score']:.3f}") + top3 = bench_df.nlargest(3, "score")[["agent_normalized", "model_normalized", "score"]] + click.echo(" Top 3:") + for _, row in top3.iterrows(): + click.echo(f" {row['agent_normalized']:20s} + {row['model_normalized']:20s} = {row['score']:.3f}") + + +@analyse_cmd.command("tool-shortlist") +@click.argument("csv_path", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +def analyse_tool_shortlist_cmd(csv_path: Path) -> None: + """Tool shortlisting effect (AppWorld).""" + _ensure_analysis_deps() + df, _ = _load_normalized_frames(csv_path) + appworld = df[df["benchmark"] == "AppWorld"].copy() + click.echo(f"Total AppWorld rows: {len(appworld)}") + for model in ["gpt-5.2", "claude-opus-4.5", "gemini-3-pro"]: + click.echo(f"\n{model}:") + no_shortlist = appworld[ + (appworld["model_normalized"] == model) & (appworld["agent_normalized"] == "litellm-react") + ] + with_shortlist = appworld[ + (appworld["model_normalized"] == model) & (appworld["agent_normalized"] == "litellm-shortlist") + ] + if len(no_shortlist) > 0: + score_no = no_shortlist["score"].values[0] + click.echo(f" Without shortlist: {score_no:.3f}") + else: + click.echo(" Without shortlist: NO DATA") + score_no = None + if len(with_shortlist) > 0: + score_with = with_shortlist["score"].values[0] + click.echo(f" With shortlist: {score_with:.3f}") + if score_no is not None: + click.echo(f" Delta: {score_with - score_no:+.3f}") + else: + click.echo(" With shortlist: NO DATA") + + +@analyse_cmd.command("cost-efficiency") +@click.argument("csv_path", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +def analyse_cost_efficiency_cmd(csv_path: Path) -> None: + """Cost-efficiency analysis (benchmark-weighted).""" + _ensure_analysis_deps() + _, valid_df = _load_normalized_frames(csv_path) + if "avg_cost" not in valid_df.columns or valid_df["avg_cost"].notna().sum() == 0: + raise click.ClickException("avg_cost is required for cost-efficiency analysis.") + cost_df = valid_df[valid_df["avg_cost"].notna()].copy() + cost_df["efficiency"] = cost_df["score"] / cost_df["avg_cost"] + + by_config = ( + cost_df.groupby(["agent_normalized", "model_normalized"]) + .apply( + lambda g: pd.Series( + { + "score": np.average(g["score"], weights=g["benchmark_weight"]), + "avg_cost": np.average(g["avg_cost"], weights=g["benchmark_weight"]), + } + ) + ) + .reset_index() + ) + by_config["efficiency"] = by_config["score"] / by_config["avg_cost"] + top_efficient = by_config.nlargest(10, "efficiency") + click.echo(f"{'Agent':<20} {'Model':<20} {'Score':>8} {'Cost':>10} {'Efficiency':>12}") + click.echo("-" * 75) + for _, row in top_efficient.iterrows(): + click.echo( + f"{row['agent_normalized']:<20} {row['model_normalized']:<20} " + f"{row['score']:>8.3f} ${row['avg_cost']:>9.2f} {row['efficiency']:>12.2f}" + ) + + +@analyse_cmd.command("component-impact") +@click.argument("csv_path", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +def analyse_component_impact_cmd(csv_path: Path) -> None: + """Component impact analysis.""" + _ensure_analysis_deps() + _, valid_df = _load_normalized_frames(csv_path) + agent_components = { + "litellm-react": { + "runtime": False, + "shortlist": False, + "schema_guard": False, + "memory": False, + "planning": False, + }, + "litellm-shortlist": { + "runtime": False, + "shortlist": True, + "schema_guard": False, + "memory": False, + "planning": False, + }, + "smolagents": { + "runtime": True, + "shortlist": False, + "schema_guard": True, + "memory": False, + "planning": False, + }, + "openai-mcp": { + "runtime": False, + "shortlist": False, + "schema_guard": True, + "memory": False, + "planning": False, + }, + "claude-code": { + "runtime": True, + "shortlist": False, + "schema_guard": True, + "memory": True, + "planning": True, + }, + } + + for comp in ["runtime", "shortlist", "schema_guard", "memory", "planning"]: + valid_df[comp] = valid_df["agent_normalized"].apply(lambda a, c=comp: agent_components.get(a, {}).get(c, False)) + + click.echo(f"{'Component':<15} {'With':>8} {'Without':>8} {'Delta':>8} {'N_with':>8} {'N_without':>10}") + click.echo("-" * 70) + for comp in ["runtime", "shortlist", "schema_guard", "memory", "planning"]: + with_comp_df = valid_df[valid_df[comp]] + without_comp_df = valid_df[~valid_df[comp]] + if len(with_comp_df) > 0 and len(without_comp_df) > 0: + mean_with = np.average(with_comp_df["score"], weights=with_comp_df["benchmark_weight"]) + mean_without = np.average(without_comp_df["score"], weights=without_comp_df["benchmark_weight"]) + delta = mean_with - mean_without + click.echo( + f"{comp.replace('_', ' ').title():<15} {mean_with:>8.3f} {mean_without:>8.3f} " + f"{delta:>+8.3f} {len(with_comp_df):>8} {len(without_comp_df):>10}" + ) + + +@analyse_cmd.command("correlation") +@click.argument("csv_path", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +def analyse_correlation_cmd(csv_path: Path) -> None: + """Cross-benchmark rank correlation.""" + _ensure_analysis_deps() + _, valid_df = _load_normalized_frames(csv_path) + pivot = valid_df.pivot_table( + index=["agent_normalized", "model_normalized"], + columns="benchmark", + values="score", + ) + if pivot.shape[1] < 2: + raise click.ClickException("Need at least 2 benchmarks for correlation.") + corr = pivot.corr(method="spearman") + click.echo("Spearman Rank Correlation Matrix:") + click.echo(corr.round(2).to_string()) + click.echo("\nNotable correlations (|r| > 0.7):") + for i in range(len(corr.columns)): + for j in range(i + 1, len(corr.columns)): + val = corr.iloc[i, j] + if abs(val) > 0.7: + click.echo(f" {corr.columns[i]} vs {corr.columns[j]}: {val:.2f}") + + +@analyse_cmd.command("cost-score") +@click.argument("csv_path", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.option( + "--output", + "output_path", + type=click.Path(dir_okay=False, path_type=Path), + default=None, + help="Output PDF path for the cost-performance plot.", +) +def analyse_cost_score_cmd(csv_path: Path, output_path: Path | None) -> None: + """Generate cost vs score plot with Pareto frontier.""" + _ensure_analysis_deps() + df, _ = _load_normalized_frames(csv_path) + df["benchmark_weight"] = df["benchmark"].apply(_get_benchmark_weight) + + weighted_scores = _compute_weighted_scores(df) + if "avg_cost" not in df.columns or df["avg_cost"].notna().sum() == 0: + raise click.ClickException("avg_cost is required for cost-score plot.") + + cost_df = df[df["avg_cost"].notna()].copy() + cost_by_config = cost_df.groupby(["agent_normalized", "model_normalized"]).agg({"avg_cost": "mean"}).reset_index() + + agent_display_names = { + "claude-code": "Claude Code", + "litellm-react": "ReAct", + "litellm-shortlist": "ReAct Short", + "openai-mcp": "Solo", + "smolagents": "Smolagent", + } + model_display_names = { + "claude-opus-4.5": "Opus", + "gemini-3-pro": "Gemini", + "gpt-5.2": "GPT", + } + + plot_data = [] + for _, row in cost_by_config.iterrows(): + agent = row["agent_normalized"] + model = row["model_normalized"] + config = (agent, model) + if config in weighted_scores: + plot_data.append( + { + "agent": agent, + "model": model, + "cost": row["avg_cost"], + "score": weighted_scores[config], + "label": f"{agent_display_names.get(agent, agent)}\n{model_display_names.get(model, model)}", + } + ) + + if not plot_data: + raise click.ClickException("No plot data available.") + + plot_df = pd.DataFrame(plot_data) + + plt.rcParams.update( + { + "font.family": "serif", + "font.size": 11, + "axes.labelsize": 12, + "axes.titlesize": 14, + "legend.fontsize": 10, + "xtick.labelsize": 10, + "ytick.labelsize": 10, + } + ) + + fig, ax = plt.subplots(figsize=(6, 4)) + for _idx, (_, row) in enumerate(plot_df.iterrows()): + shape = AGENT_SHAPES[row["agent"]] + color = MODEL_COLORS[row["model"]] + marker_size = 260 if shape == "*" else 200 + ax.scatter( + row["cost"], + row["score"], + marker=shape, + s=marker_size, + c=color, + edgecolors="black", + linewidths=0.8, + alpha=0.9, + zorder=3, + ) + + # Labels intentionally disabled. + + points = list(zip(plot_df["cost"].tolist(), plot_df["score"].tolist())) + pareto = _compute_pareto_frontier(points) + if pareto: + xs, ys = zip(*pareto) + ax.plot(xs, ys, linestyle="--", color="gray", linewidth=1.5, zorder=2) + + ax.set_xlabel("Average Cost per Task ($)", fontsize=12) + ax.set_ylabel("Success Rate", fontsize=12) + y_min = max(0, plot_df["score"].min() - 0.05) + y_max = min(1.0, plot_df["score"].max() + 0.08) + ax.set_ylim(y_min, y_max) + ax.grid(True, alpha=0.2, linestyle=":", linewidth=0.8, color="gray", zorder=1) + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + ax.spines["bottom"].set_visible(False) + ax.spines["left"].set_visible(False) + + # Legends + agent_labels = { + "claude-code": "Claude Code", + "litellm-react": "ReAct", + "litellm-shortlist": "ReAct Short", + "openai-mcp": "OpenAI Solo", + "smolagents": "Smolagent", + } + model_labels = { + "claude-opus-4.5": "Opus 4.5", + "gemini-3-pro": "Gemini 3", + "gpt-5.2": "GPT 5.2", + } + + agent_handles = [] + agent_label_list = [] + for agent in AGENT_SHAPES: + agent_handles.append( + plt.Line2D( + [0], + [0], + marker=AGENT_SHAPES[agent], + color="w", + markerfacecolor="gray", + markeredgecolor="black", + markersize=8, + linestyle="", + ) + ) + agent_label_list.append(agent_labels[agent]) + + model_handles = [] + model_label_list = [] + for model in MODEL_COLORS: + model_handles.append( + plt.Line2D( + [0], + [0], + marker="o", + color="w", + markerfacecolor=MODEL_COLORS[model], + markeredgecolor="black", + markersize=8, + linestyle="", + ) + ) + model_label_list.append(model_labels[model]) + + legend1 = ax.legend( + model_handles, + model_label_list, + loc="lower right", + bbox_to_anchor=(0.70, 0.05), + frameon=False, + fontsize=8, + title="Model", + title_fontsize=9, + ) + ax.add_artist(legend1) + + ax.legend( + agent_handles, + agent_label_list, + loc="lower right", + bbox_to_anchor=(0.95, 0.05), + frameon=False, + fontsize=8, + title="Agent", + title_fontsize=9, + ) + + if output_path is None: + output_path = _project_root() / "misc" / "paper" / "figures" / "cost_performance.pdf" + output_path.parent.mkdir(parents=True, exist_ok=True) + plt.tight_layout() + plt.savefig(output_path, dpi=300, bbox_inches="tight") + plt.savefig(output_path.with_suffix(".png"), dpi=150, bbox_inches="tight") + click.echo(f"Saved to {output_path}") + click.echo(f"Saved to {output_path.with_suffix('.png')}") + + +def _save_pdf_png(fig: Any, output_pdf: Path) -> None: + output_pdf.parent.mkdir(parents=True, exist_ok=True) + fig.tight_layout() + fig.savefig(output_pdf, dpi=300, bbox_inches="tight") + fig.savefig(output_pdf.with_suffix(".png"), dpi=150, bbox_inches="tight") + plt.close(fig) + + +@analyse_cmd.command("paper-figures") +@click.argument("csv_path", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.option( + "--outdir", + type=click.Path(file_okay=False, path_type=Path), + default=None, + help="Output directory for generated figures.", +) +def analyse_paper_figures_cmd(csv_path: Path, outdir: Path | None) -> None: + """Generate the paper graph set from one CSV.""" + _ensure_analysis_deps() + df, valid_df = _load_normalized_frames(csv_path) + if outdir is None: + outdir = _project_root() / "misc" / "paper" / "figures" + outdir.mkdir(parents=True, exist_ok=True) + + # 1) Cost-performance (reuse existing command output default file name). + analyse_cost_score_cmd.callback(csv_path=csv_path, output_path=outdir / "cost_performance.pdf") # type: ignore[attr-defined] + + # 2) Results heatmap (config x benchmark). + heat = valid_df.pivot_table( + index=["agent_normalized", "model_normalized"], + columns="benchmark", + values="score", + aggfunc="mean", + ) + fig, ax = plt.subplots(figsize=(9, 6)) + im = ax.imshow(heat.values, aspect="auto", cmap="YlGnBu", vmin=0.0, vmax=1.0) + ax.set_xticks(range(len(heat.columns))) + ax.set_xticklabels(heat.columns, rotation=30, ha="right") + ylabels = [f"{a} | {m}" for a, m in heat.index] + ax.set_yticks(range(len(ylabels))) + ax.set_yticklabels(ylabels) + ax.set_title("Results Heatmap") + fig.colorbar(im, ax=ax, label="Score") + _save_pdf_png(fig, outdir / "results_heatmap.pdf") + click.echo(f"Saved to {outdir / 'results_heatmap.pdf'}") + click.echo(f"Saved to {(outdir / 'results_heatmap.pdf').with_suffix('.png')}") + + # 3) Benchmark correlation heatmap (Spearman). + corr = heat.corr(method="spearman") + fig, ax = plt.subplots(figsize=(7, 6)) + im = ax.imshow(corr.values, cmap="coolwarm", vmin=-1.0, vmax=1.0) + ax.set_xticks(range(len(corr.columns))) + ax.set_xticklabels(corr.columns, rotation=30, ha="right") + ax.set_yticks(range(len(corr.index))) + ax.set_yticklabels(corr.index) + ax.set_title("Benchmark Correlation (Spearman)") + fig.colorbar(im, ax=ax, label="Correlation") + _save_pdf_png(fig, outdir / "benchmark_correlation.pdf") + click.echo(f"Saved to {outdir / 'benchmark_correlation.pdf'}") + click.echo(f"Saved to {(outdir / 'benchmark_correlation.pdf').with_suffix('.png')}") + + # 4) Protocol comparison. + protocol_map = { + "litellm-react": "Tool-calling", + "litellm-shortlist": "Tool-calling", + "smolagents": "Python-functions", + "openai-mcp": "MCP", + "claude-code": "MCP", + } + by_protocol = valid_df.assign(protocol=valid_df["agent_normalized"].map(protocol_map)).dropna(subset=["protocol"]) + agg = ( + by_protocol.groupby(["benchmark", "protocol"])["score"] + .mean() + .reset_index() + .pivot(index="benchmark", columns="protocol", values="score") + ) + fig, ax = plt.subplots(figsize=(9, 5)) + agg.plot(kind="bar", ax=ax, rot=25) + ax.set_ylim(0, 1) + ax.set_ylabel("Mean Score") + ax.set_title("Protocol Comparison by Benchmark") + ax.legend(frameon=False) + _save_pdf_png(fig, outdir / "protocol_comparison.pdf") + click.echo(f"Saved to {outdir / 'protocol_comparison.pdf'}") + click.echo(f"Saved to {(outdir / 'protocol_comparison.pdf').with_suffix('.png')}") + + # 5) Component impact. + agent_components = { + "litellm-react": { + "runtime": False, + "shortlist": False, + "schema_guard": False, + "memory": False, + "planning": False, + }, + "litellm-shortlist": { + "runtime": False, + "shortlist": True, + "schema_guard": False, + "memory": False, + "planning": False, + }, + "smolagents": { + "runtime": True, + "shortlist": False, + "schema_guard": True, + "memory": False, + "planning": False, + }, + "openai-mcp": { + "runtime": False, + "shortlist": False, + "schema_guard": True, + "memory": False, + "planning": False, + }, + "claude-code": { + "runtime": True, + "shortlist": False, + "schema_guard": True, + "memory": True, + "planning": True, + }, + } + tmp = valid_df.copy() + components = ["runtime", "shortlist", "schema_guard", "memory", "planning"] + deltas = [] + for comp in components: + tmp[comp] = tmp["agent_normalized"].apply(lambda a, c=comp: agent_components.get(a, {}).get(c, False)) + with_comp = tmp[tmp[comp]]["score"] + without_comp = tmp[~tmp[comp]]["score"] + if len(with_comp) and len(without_comp): + deltas.append((comp, with_comp.mean() - without_comp.mean())) + fig, ax = plt.subplots(figsize=(8, 4.5)) + labels = [c for c, _ in deltas] + values = [v for _, v in deltas] + colors = ["#2a9d8f" if v >= 0 else "#e76f51" for v in values] + ax.barh(labels, values, color=colors) + ax.axvline(0, color="black", linewidth=1.0) + ax.set_xlabel("Score Delta (With - Without)") + ax.set_title("Component Impact") + _save_pdf_png(fig, outdir / "component_impact.pdf") + click.echo(f"Saved to {outdir / 'component_impact.pdf'}") + click.echo(f"Saved to {(outdir / 'component_impact.pdf').with_suffix('.png')}") + + # 6) Best configuration per benchmark. + winners = ( + valid_df.sort_values("score", ascending=False) + .groupby("benchmark", as_index=False) + .first() + .sort_values("score", ascending=True) + ) + fig, ax = plt.subplots(figsize=(9, 5)) + labels = [ + f"{b}\n{a} + {m}" + for b, a, m in zip( + winners["benchmark"], + winners["agent_normalized"], + winners["model_normalized"], + ) + ] + ax.barh(labels, winners["score"], color="#457b9d") + ax.set_xlim(0, 1) + ax.set_xlabel("Score") + ax.set_title("Best Per Benchmark") + _save_pdf_png(fig, outdir / "best_per_benchmark.pdf") + click.echo(f"Saved to {outdir / 'best_per_benchmark.pdf'}") + click.echo(f"Saved to {(outdir / 'best_per_benchmark.pdf').with_suffix('.png')}") + + +__all__ = [ + "analyse_cmd", + "analyse_leaderboard_cmd", + "analyse_leaderboard_paper_cmd", + "analyse_model_agent_cmd", + "analyse_model_win_rate_cmd", + "analyse_config_win_rate_cmd", + "analyse_best_per_benchmark_cmd", + "analyse_tool_shortlist_cmd", + "analyse_cost_efficiency_cmd", + "analyse_component_impact_cmd", + "analyse_correlation_cmd", + "analyse_cost_score_cmd", + "analyse_paper_figures_cmd", +] diff --git a/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/commands/batch.py b/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/commands/batch.py new file mode 100644 index 00000000..0e7fc937 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/commands/batch.py @@ -0,0 +1,1031 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import csv +import glob +import json +import os +import sys +from pathlib import Path +from typing import Any + +import rich_click as click + +from ....core.types import RunConfig, SessionConfig +from ....core.types.session import SessionExecutionStatus, SessionOutcomeStatus +from ....utils.paths import get_run_paths, get_session_paths +from ...lib.api import aggregate, evaluate, execute, status +from ..options import ( + _format_exception_for_cli, + _should_show_traceback, + apply_debug_mode, +) +from ..render import render_batch_status + + +def _load_config_file(path: str) -> dict[str, Any]: + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def _load_run_like_config(path: str) -> RunConfig | SessionConfig: + """Load a RunConfig or SessionConfig from a file with full validation.""" + payload = _load_config_file(path) + try: + return RunConfig.model_validate(payload) + except Exception: + return SessionConfig.model_validate(payload) + + +def _run_config_from_session(session_config: SessionConfig) -> RunConfig: + return RunConfig( + benchmark=session_config.benchmark, + agent=session_config.agent, + subset=session_config.subset, + task_ids=[session_config.task_id], + output_dir=session_config.output_dir, + cache_dir=session_config.cache_dir, + run_id=session_config.run_id, + model=session_config.model, + benchmark_kwargs=session_config.benchmark_kwargs, + agent_kwargs=session_config.agent_kwargs, + ) + + +def _state_from_counts( + *, + total: int, + completed: int, + running: int, + incomplete: int, + missing: int, +) -> str: + if total > 0 and completed == total and running == 0 and incomplete == 0: + return "complete" + if total > 0 and completed == 0 and running == 0 and incomplete == 0 and missing == total: + return "not_started" + return "in_progress" + + +def _short_config_path(path: str) -> str: + try: + rel = os.path.relpath(path) + except Exception: + rel = path + if rel.startswith(".."): + return Path(path).name + return rel + + +def _truncate_leading(text: str, max_len: int) -> str: + if max_len <= 3 or len(text) <= max_len: + return text + return "..." + text[-(max_len - 3) :] + + +def _format_batch_error(exc: Exception) -> str: + if _should_show_traceback(): + details = _format_exception_for_cli(exc) + else: + details = str(exc) + if not details: + details = repr(exc) + return details.replace("\n", "\n ") + + +def _format_config_link(path: str, *, max_len: int = 30) -> str: + display = _short_config_path(path) + display = _truncate_leading(display, max_len) + try: + target = Path(path).resolve() + except Exception: + return display + return f"[link=file://{target}]{display}[/link]" + + +def _format_float(value: Any) -> str: + try: + num = float(value) + except Exception: + return "-" + if abs(num) >= 1000: + return f"{num:,.0f}" + if abs(num) >= 10: + return f"{num:.2f}" + return f"{num:.4g}" + + +def _csv_value(value: Any) -> str: + if value is None: + return "" + if isinstance(value, (str, int, float, bool)): + return str(value) + try: + return json.dumps(value, ensure_ascii=False) + except Exception: + return str(value) + + +def _write_session_config(session_config: SessionConfig, *, overwrite: bool) -> bool: + session_id = session_config.get_session_id() + sess_paths = get_session_paths(session_id) + config_path = sess_paths.session_config + if config_path.exists() and not overwrite: + return False + config_path.parent.mkdir(parents=True, exist_ok=True) + with open(config_path, "w", encoding="utf-8") as f: + json.dump( + session_config.model_dump(mode="json"), + f, + ensure_ascii=False, + indent=2, + ) + return True + + +def _format_models(value: Any) -> str: + if value is None: + return "-" + if isinstance(value, list): + names = [str(item) for item in value if item is not None] + return ", ".join(names) if names else "-" + return str(value) + + +def _compute_session_id(config: dict[str, Any]) -> str: + session_cfg = SessionConfig.model_validate(config) + return session_cfg.get_session_id() + + +def _results_path_from_config_location(config_path: str, config_obj: RunConfig | SessionConfig) -> Path: + run_id = getattr(config_obj, "run_id", None) + if not run_id: + raise click.ClickException(f"Missing run_id in config: {config_path}") + output_dir = Path(config_path).parent + return (output_dir / run_id / "results.json").resolve() + + +def _parse_patch_values(pairs: tuple[str, ...]) -> dict[str, Any]: + updates: dict[str, Any] = {} + for pair in pairs: + if "=" not in pair: + raise click.ClickException(f"Invalid --set value (expected key=value): {pair}") + key, raw = pair.split("=", 1) + key = key.strip() + if not key: + raise click.ClickException(f"Invalid --set key: {pair}") + try: + value = json.loads(raw) + except Exception: + value = raw + updates[key] = value + return updates + + +def _apply_patch(payload: dict[str, Any], updates: dict[str, Any]) -> None: + for key, value in updates.items(): + if key.startswith("agent."): + key = f"agent_kwargs.{key[len('agent.'):]}" + elif key.startswith("benchmark."): + key = f"benchmark_kwargs.{key[len('benchmark.'):]}" + if "." not in key: + payload[key] = value + continue + cursor: dict[str, Any] = payload + parts = key.split(".") + for part in parts[:-1]: + if part not in cursor or not isinstance(cursor[part], dict): + cursor[part] = {} + cursor = cursor[part] + cursor[parts[-1]] = value + + +def _update_session_ids_in_dir(session_dir: Path, new_id: str) -> None: + for json_path in session_dir.rglob("*.json"): + try: + payload = _load_config_file(str(json_path)) + except Exception: + continue + if not isinstance(payload, dict) or "session_id" not in payload: + continue + if payload.get("session_id") == new_id: + continue + payload["session_id"] = new_id + with open(json_path, "w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, indent=2) + f.write("\n") + + +def _extract_task_id_from_session(session_dir: Path) -> str | None: + results_path = session_dir / "results.json" + if not results_path.exists(): + return None + try: + payload = _load_config_file(str(results_path)) + except Exception: + return None + if not isinstance(payload, dict): + return None + task_id = payload.get("task_id") or payload.get("task_key") + return str(task_id) if task_id is not None else None + + +def _recover_session_hashes(roots: list[Path], *, do_apply: bool) -> int: + changes = 0 + for root in roots: + sessions_root = root / "sessions" + if not sessions_root.exists(): + raise click.ClickException(f"sessions dir not found: {sessions_root}") + for cfg_path in sessions_root.rglob("config.json"): + if cfg_path.parent.parent != sessions_root: + continue + session_dir = cfg_path.parent + old_id = session_dir.name + config = _load_config_file(str(cfg_path)) + new_id = _compute_session_id(config) + if new_id == old_id: + continue + changes += 1 + click.echo(f"{old_id} -> {new_id} ({session_dir})") + if not do_apply: + continue + if "session_id" in config: + config["session_id"] = new_id + with open(cfg_path, "w", encoding="utf-8") as f: + json.dump(config, f, ensure_ascii=False, indent=2) + f.write("\n") + _update_session_ids_in_dir(session_dir, new_id) + session_dir.rename(session_dir.with_name(new_id)) + return changes + + +def _load_results_summary( + path: str, +) -> tuple[str, str, str, int | None, int | None, int | None, int | None]: + if not path or not Path(path).is_file(): + return "-", "-", "-", None, None, None, None + try: + payload = _load_config_file(path) + except Exception: + return "-", "-", "-", None, None, None, None + score = payload.get("benchmark_score") + if score is None: + score = payload.get("average_score") + cost = payload.get("total_run_cost") + if cost is None: + cost = payload.get("total_agent_cost") + models = payload.get("model_names") + if models is None: + models = payload.get("model_name") + ready = None + finished = None + errors = None + aggregated = None + session_results = payload.get("session_results") + aggregated_ids = payload.get("aggregated_session_ids") + if isinstance(aggregated_ids, list): + aggregated = len(aggregated_ids) + elif isinstance(payload.get("completed_sessions"), int): + aggregated = payload.get("completed_sessions") + if isinstance(session_results, list): + ready = 0 + finished = 0 + errors = 0 + for item in session_results: + if not isinstance(item, dict): + continue + status = str(item.get("status") or "").lower() + is_finished = item.get("is_finished") + if status in ("error", "cancelled"): + errors += 1 + else: + ready += 1 + if is_finished is True: + finished += 1 + return ( + _format_float(score), + _format_float(cost), + _format_models(models), + ready, + finished, + errors, + aggregated, + ) + + +def _expand_config_inputs( + config_values: tuple[str, ...], + extra_args: list[str], +) -> list[str]: + if not config_values: + raise click.ClickException("At least one --config is required.") + + raw_values = [*config_values, *extra_args] + expanded: list[str] = [] + + for value in raw_values: + value = str(value).strip() + if not value: + continue + if glob.has_magic(value): + matches = [str(Path(p)) for p in sorted(glob.glob(value, recursive=True)) if Path(p).is_file()] + if not matches: + raise click.ClickException(f"No config files matched pattern: {value}") + expanded.extend(matches) + else: + path = Path(value) + if not path.is_file(): + raise click.ClickException(f"Config file not found: {value}") + expanded.append(str(path)) + + deduped: list[str] = [] + seen: set[str] = set() + for path in expanded: + normalized = str(Path(path)) + if normalized in seen: + continue + seen.add(normalized) + deduped.append(normalized) + if not deduped: + raise click.ClickException("No config files resolved from --config values.") + return deduped + + +@click.group("batch") +@click.option( + "--debug", + is_flag=True, + help="Enable debug mode (sets settings.debug=true and log level to DEBUG)", +) +def batch_cmd(debug: bool) -> None: + """Batch operations over multiple config files.""" + apply_debug_mode(debug) + + +@batch_cmd.command( + "status", + context_settings={"allow_extra_args": True}, +) +@click.option( + "--debug", + is_flag=True, + help="Enable debug mode (sets settings.debug=true and log level to DEBUG)", +) +@click.option( + "--config", + "config_values", + multiple=True, + help="RunConfig/SessionConfig path or glob pattern (repeatable).", +) +@click.pass_context +def batch_status_cmd( + ctx: click.Context, + debug: bool, + config_values: tuple[str, ...], +) -> None: + """Show a status table for multiple config files.""" + apply_debug_mode(debug) + config_paths = _expand_config_inputs(config_values, list(ctx.args)) + rows: list[dict[str, str]] = [] + + for i, config_path in enumerate(config_paths, start=1): + row: dict[str, str] = { + "#": str(i), + "config": _format_config_link(config_path, max_len=30), + "run_id": "-", + "benchmark": "-", + "agent": "-", + "subset": "-", + "models": "-", + "ready": "-", + "aggregated": "-", + "finished": "-", + "errors": "-", + "score": "-", + "cost": "-", + } + try: + cfg = _load_run_like_config(config_path) + run_status = status(cfg) + ( + score, + cost, + models, + ready, + finished, + errors, + aggregated, + ) = _load_results_summary(run_status.results_path) + if models == "-": + models = run_status.model_name or "-" + models = _truncate_leading(models, 24) + if ready is None or finished is None or errors is None: + ready = 0 + finished = 0 + errors = 0 + if aggregated is None: + aggregated = 0 + for item in run_status.session_statuses: + if item.status != SessionExecutionStatus.COMPLETED: + continue + if item.result_status in ( + SessionOutcomeStatus.ERROR, + SessionOutcomeStatus.CANCELLED, + ): + errors += 1 + else: + ready += 1 + if aggregated is not None: + aggregated += 1 + if item.result_status in ( + SessionOutcomeStatus.SUCCESS, + SessionOutcomeStatus.UNSUCCESSFUL, + ): + finished += 1 + row.update( + { + "run_id": run_status.run_id, + "benchmark": run_status.benchmark_slug_name, + "agent": run_status.agent_slug_name, + "subset": run_status.subset_name or "-", + "models": models, + "ready": f"{ready}/{run_status.total_tasks}", + "aggregated": f"{aggregated}/{run_status.total_tasks}", + "finished": f"{finished}/{run_status.total_tasks}", + "errors": f"{errors}/{run_status.total_tasks}", + "score": score, + "cost": cost, + } + ) + except Exception: + pass + rows.append(row) + + render_batch_status(rows) + + +@batch_cmd.command( + "evaluate", + context_settings={"allow_extra_args": True}, +) +@click.option( + "--debug", + is_flag=True, + help="Enable debug mode (sets settings.debug=true and log level to DEBUG)", +) +@click.option( + "--config", + "config_values", + multiple=True, + help="RunConfig/SessionConfig path or glob pattern (repeatable).", +) +@click.pass_context +def batch_evaluate_cmd( + ctx: click.Context, + debug: bool, + config_values: tuple[str, ...], +) -> None: + """Evaluate configs sequentially.""" + apply_debug_mode(debug) + config_paths = _expand_config_inputs(config_values, list(ctx.args)) + failures: list[tuple[str, str]] = [] + + for config_path in config_paths: + click.echo(f"Running: {config_path}") + try: + cfg = _load_run_like_config(config_path) + evaluate(config=cfg) + except Exception as exc: + failures.append((config_path, str(exc))) + click.echo(f"Error: {config_path}\n {_format_batch_error(exc)}") + + if failures: + raise click.ClickException("Batch evaluate completed with errors in " + ", ".join(path for path, _ in failures)) + + +@batch_cmd.command( + "execute", + context_settings={"allow_extra_args": True}, +) +@click.option( + "--debug", + is_flag=True, + help="Enable debug mode (sets settings.debug=true and log level to DEBUG)", +) +@click.option( + "--config", + "config_values", + multiple=True, + help="RunConfig/SessionConfig path or glob pattern (repeatable).", +) +@click.pass_context +def batch_execute_cmd( + ctx: click.Context, + debug: bool, + config_values: tuple[str, ...], +) -> None: + """Execute configs sequentially (no aggregation).""" + apply_debug_mode(debug) + config_paths = _expand_config_inputs(config_values, list(ctx.args)) + failures: list[tuple[str, str]] = [] + + for config_path in config_paths: + click.echo(f"Running: {config_path}") + try: + cfg = _load_run_like_config(config_path) + execute(config=cfg) + except Exception as exc: + failures.append((config_path, str(exc))) + click.echo(f"Error: {config_path}\n {_format_batch_error(exc)}") + + if failures: + raise click.ClickException("Batch execute completed with errors in " + ", ".join(path for path, _ in failures)) + + +@batch_cmd.command( + "prepare", + context_settings={"allow_extra_args": True}, +) +@click.option( + "--debug", + is_flag=True, + help="Enable debug mode (sets settings.debug=true and log level to DEBUG)", +) +@click.option( + "--overwrite", + is_flag=True, + help="Overwrite existing session config files.", +) +@click.option( + "--config", + "config_values", + multiple=True, + help="RunConfig/SessionConfig path or glob pattern (repeatable).", +) +@click.pass_context +def batch_prepare_cmd( + ctx: click.Context, + debug: bool, + overwrite: bool, + config_values: tuple[str, ...], +) -> None: + """Prepare session directories and configs without executing.""" + apply_debug_mode(debug) + config_paths = _expand_config_inputs(config_values, list(ctx.args)) + failures: list[tuple[str, str]] = [] + + for config_path in config_paths: + click.echo(f"Preparing: {config_path}") + try: + cfg = _load_run_like_config(config_path) + if isinstance(cfg, RunConfig): + with cfg.get_context(): + session_configs = cfg.get_session_configs() + created = 0 + for sc in session_configs: + if _write_session_config(sc, overwrite=overwrite): + created += 1 + click.echo(f"Prepared {created}/{len(session_configs)} sessions.") + else: + with cfg.get_context(): + created = 1 if _write_session_config(cfg, overwrite=overwrite) else 0 + click.echo(f"Prepared {created}/1 sessions.") + except Exception as exc: + failures.append((config_path, str(exc))) + click.echo(f"Error: {config_path}\n {_format_batch_error(exc)}") + + if failures: + raise click.ClickException("Batch prepare completed with errors in " + ", ".join(path for path, _ in failures)) + + +@batch_cmd.command( + "aggregate", + context_settings={"allow_extra_args": True}, +) +@click.option( + "--debug", + is_flag=True, + help="Enable debug mode (sets settings.debug=true and log level to DEBUG)", +) +@click.option( + "--config", + "config_values", + multiple=True, + help="RunConfig/SessionConfig path or glob pattern (repeatable).", +) +@click.pass_context +def batch_aggregate_cmd( + ctx: click.Context, + debug: bool, + config_values: tuple[str, ...], +) -> None: + """Aggregate configs sequentially (no execution).""" + apply_debug_mode(debug) + config_paths = _expand_config_inputs(config_values, list(ctx.args)) + failures: list[tuple[str, str]] = [] + + for config_path in config_paths: + click.echo(f"Running: {config_path}") + try: + cfg = _load_run_like_config(config_path) + aggregate(config=cfg) + except Exception as exc: + failures.append((config_path, str(exc))) + click.echo(f"Error: {config_path}\n {_format_batch_error(exc)}") + + if failures: + raise click.ClickException( + "Batch aggregate completed with errors in " + ", ".join(path for path, _ in failures) + ) + + +@batch_cmd.command( + "patch", + context_settings={"allow_extra_args": True}, +) +@click.option( + "--config", + "config_values", + multiple=True, + help="RunConfig/SessionConfig path or glob pattern (repeatable).", +) +@click.option( + "--set", + "set_values", + multiple=True, + help="key=value pairs to update (repeatable). Supports dotted paths.", +) +@click.option( + "--apply", + is_flag=True, + help="Apply changes (otherwise dry-run).", +) +@click.option( + "--dry-run", + is_flag=True, + help="Preview changes without applying.", +) +@click.pass_context +def batch_patch_cmd( + ctx: click.Context, + config_values: tuple[str, ...], + set_values: tuple[str, ...], + apply: bool, + dry_run: bool, +) -> None: + """Patch run/session configs and recover session hashes.""" + if apply and dry_run: + raise click.ClickException("Use only one of --apply or --dry-run.") + if not (config_values or ctx.args): + raise click.ClickException("Provide at least one --config.") + if not set_values: + raise click.ClickException("Provide at least one --set key=value.") + do_apply = apply and not dry_run + updates = _parse_patch_values(set_values) + + config_paths = _expand_config_inputs(config_values, list(ctx.args)) + run_roots: list[Path] = [] + for config_path in config_paths: + cfg = _load_run_like_config(config_path) + with cfg.get_context(): + run_paths = get_run_paths() + run_roots.append(Path(run_paths.root)) + payload = _load_config_file(config_path) + _apply_patch(payload, updates) + click.echo(f"Patch config: {config_path}") + if do_apply: + with open(config_path, "w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, indent=2) + f.write("\n") + + seen_roots: set[Path] = set() + for root in run_roots: + if root in seen_roots: + continue + seen_roots.add(root) + run_config_path = root / "run" / "config.json" + run_config_payload: dict[str, Any] | None = None + if run_config_path.exists(): + run_config_payload = _load_config_file(str(run_config_path)) + _apply_patch(run_config_payload, updates) + click.echo(f"Patch run config: {run_config_path}") + if do_apply: + with open(run_config_path, "w", encoding="utf-8") as f: + json.dump(run_config_payload, f, ensure_ascii=False, indent=2) + f.write("\n") + run_config_obj = RunConfig.model_validate(run_config_payload) if run_config_payload is not None else None + + sessions_root = root / "sessions" + if not sessions_root.exists(): + raise click.ClickException(f"sessions dir not found: {sessions_root}") + changes = 0 + for cfg_path in sessions_root.rglob("config.json"): + if cfg_path.parent.parent != sessions_root: + continue + session_dir = cfg_path.parent + old_id = session_dir.name + config = _load_config_file(str(cfg_path)) + _apply_patch(config, updates) + new_id = _compute_session_id(config) + if new_id != old_id: + changes += 1 + click.echo(f"{old_id} -> {new_id} ({session_dir})") + if do_apply: + with open(cfg_path, "w", encoding="utf-8") as f: + json.dump(config, f, ensure_ascii=False, indent=2) + f.write("\n") + # Handle sessions without config.json by deriving from run config + results.json task id. + if run_config_obj is not None: + for session_dir in sessions_root.iterdir(): + if not session_dir.is_dir(): + continue + cfg_path = session_dir / "config.json" + if cfg_path.exists(): + continue + task_id = _extract_task_id_from_session(session_dir) + if task_id is None: + continue + old_id = session_dir.name + new_id = run_config_obj.to_session_config(task_id).get_session_id() + if new_id != old_id: + changes += 1 + target_dir = session_dir.with_name(new_id) + if target_dir.exists(): + alt_name = f"{new_id}__dup__{old_id}" + target_dir = session_dir.with_name(alt_name) + click.echo(f"{old_id} -> {new_id} (collision, renaming to {alt_name})") + else: + click.echo(f"{old_id} -> {new_id} ({session_dir})") + if do_apply: + _update_session_ids_in_dir(session_dir, new_id) + session_dir.rename(target_dir) + if do_apply: + _recover_session_hashes([root], do_apply=True) + + if not do_apply: + click.echo(f"Dry run complete. {changes} session(s) would be renamed.") + else: + click.echo(f"Done. {changes} session(s) renamed.") + + +@batch_cmd.command( + "extract", + context_settings={"allow_extra_args": True}, +) +@click.option( + "--config", + "config_values", + multiple=True, + help="RunConfig/SessionConfig path or glob pattern (repeatable).", +) +@click.option( + "--output", + "output_path", + default="batch_results.csv", + show_default=True, + help="CSV output path (use '-' for stdout).", +) +@click.pass_context +def batch_extract_cmd( + ctx: click.Context, + config_values: tuple[str, ...], + output_path: str, +) -> None: + """Extract run results from multiple configs into a single CSV.""" + config_paths = _expand_config_inputs(config_values, list(ctx.args)) + failures: list[tuple[str, str]] = [] + rows: list[dict[str, Any]] = [] + all_keys: set[str] = set() + preferred_keys = [ + "config_path", + "results_path", + "run_id", + ] + + for config_path in config_paths: + results_path = "-" + run_id = "-" + try: + cfg = _load_run_like_config(config_path) + run_id = getattr(cfg, "run_id", None) or "-" + results_path = _results_path_from_config_location(config_path, cfg) + if not results_path.is_file(): + raise click.ClickException(f"Results not found: {results_path}") + payload = _load_config_file(str(results_path)) + if not isinstance(payload, dict): + raise click.ClickException(f"Results JSON is not an object: {results_path}") + if "benchmark_score" not in payload: + raise click.ClickException(f"Missing benchmark_score in results: {results_path}") + row: dict[str, Any] = { + "config_path": config_path, + "results_path": str(results_path), + "run_id": run_id, + } + for key, value in payload.items(): + if key in row: + row[f"results_{key}"] = value + else: + row[key] = value + except Exception as exc: + failures.append((config_path, str(exc))) + row = { + "config_path": config_path, + "results_path": results_path, + "run_id": run_id, + "error": _format_batch_error(exc), + } + rows.append(row) + all_keys.update(row.keys()) + + ordered_keys = [k for k in preferred_keys if k in all_keys] + ordered_keys.extend(sorted(k for k in all_keys if k not in ordered_keys)) + + if output_path == "-": + out_stream = sys.stdout + writer = csv.DictWriter(out_stream, fieldnames=ordered_keys) + writer.writeheader() + for row in rows: + writer.writerow({k: _csv_value(row.get(k)) for k in ordered_keys}) + else: + out_file = Path(output_path) + out_file.parent.mkdir(parents=True, exist_ok=True) + with open(out_file, "w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=ordered_keys) + writer.writeheader() + for row in rows: + writer.writerow({k: _csv_value(row.get(k)) for k in ordered_keys}) + click.echo(f"Wrote {len(rows)} row(s) to {out_file}.") + + if failures: + raise click.ClickException("Batch extract completed with errors in " + ", ".join(path for path, _ in failures)) + + +# Fields to exclude when publishing to HuggingFace (internal/bulky data). +_PUBLISH_EXCLUDE_FIELDS: set[str] = { + "config_path", + "results_path", + "run_id", + "aggregated_session_ids", + "executed_session_ids", + "planned_session_ids", + "skipped_session_ids", + "skipped_session_reasons", + "missing_result_files", + "session_results", + "accumulated_agent_report", + "accumulated_benchmark_report", + "aggregation_mode", + "max_workers", + "running_sessions", + "model_names", + "agent_slug_name", + "benchmark_slug_name", + "planned_sessions", +} + + +def _collect_results_rows( + config_paths: list[str], +) -> tuple[list[dict[str, Any]], list[tuple[str, str]]]: + """Load results from config paths and return clean rows for publishing. + + Uses lightweight JSON loading to find results.json without importing + benchmark or agent modules, so it works even when those aren't installed. + """ + failures: list[tuple[str, str]] = [] + rows: list[dict[str, Any]] = [] + + for config_path in config_paths: + try: + # Read config as raw JSON to get run_id without importing modules + raw_config = _load_config_file(config_path) + run_id = raw_config.get("run_id") + if not run_id: + raise click.ClickException(f"Missing run_id in config: {config_path}") + output_dir = Path(config_path).parent + results_path = (output_dir / run_id / "results.json").resolve() + if not results_path.is_file(): + raise click.ClickException(f"Results not found: {results_path}") + payload = _load_config_file(str(results_path)) + if not isinstance(payload, dict): + raise click.ClickException(f"Results JSON is not an object: {results_path}") + if "benchmark_score" not in payload: + raise click.ClickException(f"Missing benchmark_score in results: {results_path}") + row: dict[str, Any] = {} + for key, value in payload.items(): + if key not in _PUBLISH_EXCLUDE_FIELDS: + row[key] = value + rows.append(row) + except Exception as exc: + failures.append((config_path, str(exc))) + + return rows, failures + + +@batch_cmd.command( + "publish", + context_settings={"allow_extra_args": True}, +) +@click.option( + "--config", + "config_values", + multiple=True, + help="RunConfig/SessionConfig path or glob pattern (repeatable).", +) +@click.option( + "--repo", + "repo_id", + required=True, + help="HuggingFace dataset repo ID (e.g. 'Exgentic/open-agent-leaderboard-results').", +) +@click.option( + "--private/--public", + "private", + default=True, + show_default=True, + help="Whether the dataset should be private.", +) +@click.option( + "--append/--overwrite", + "append", + default=True, + show_default=True, + help="Append to existing dataset or overwrite it.", +) +@click.pass_context +def batch_publish_cmd( + ctx: click.Context, + config_values: tuple[str, ...], + repo_id: str, + private: bool, + append: bool, +) -> None: + """Publish run results to a HuggingFace dataset.""" + try: + from datasets import Dataset, load_dataset + except ImportError as err: + raise click.ClickException( + "The 'datasets' package is required for publishing. Install it with: pip install datasets" + ) from err + + config_paths = _expand_config_inputs(config_values, list(ctx.args)) + rows, failures = _collect_results_rows(config_paths) + + if not rows: + raise click.ClickException("No valid results to publish.") + + if append: + try: + existing_ds = load_dataset(repo_id, split="train") + existing_rows = list(existing_ds) + click.echo(f"Loaded {len(existing_rows)} existing row(s) from {repo_id}.") + + # Deduplicate by (benchmark, agent, model) triple + existing_keys = set() + for r in existing_rows: + key = (r.get("benchmark"), r.get("agent"), r.get("model")) + existing_keys.add(key) + + new_rows = [] + updated = 0 + for row in rows: + key = (row.get("benchmark"), row.get("agent"), row.get("model")) + if key in existing_keys: + # Replace existing row with updated one + existing_rows = [ + r for r in existing_rows if (r.get("benchmark"), r.get("agent"), r.get("model")) != key + ] + updated += 1 + new_rows.append(row) + + all_rows = existing_rows + new_rows + click.echo(f"Publishing {len(all_rows)} row(s) " f"({len(new_rows) - updated} new, {updated} updated).") + except Exception: + click.echo("No existing dataset found, creating new one.") + all_rows = rows + else: + all_rows = rows + + ds = Dataset.from_list(all_rows) + ds.push_to_hub(repo_id, private=private) + click.echo(f"Published {len(all_rows)} row(s) to https://huggingface.co/datasets/{repo_id}") + + if failures: + click.echo(f"Warning: {len(failures)} config(s) had errors and were skipped:") + for path, err in failures: + click.echo(f" {path}: {err}") + + +__all__ = [ + "batch_cmd", + "batch_status_cmd", + "batch_evaluate_cmd", + "batch_execute_cmd", + "batch_prepare_cmd", + "batch_aggregate_cmd", + "batch_patch_cmd", + "batch_extract_cmd", + "batch_publish_cmd", +] diff --git a/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/commands/compare.py b/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/commands/compare.py new file mode 100644 index 00000000..97b8a7d1 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/commands/compare.py @@ -0,0 +1,1169 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, NamedTuple, Optional, Tuple, Union + +import rich_click as click +from rich.console import Console +from rich.progress import ( + BarColumn, + Progress, + SpinnerColumn, + TaskProgressColumn, + TextColumn, +) +from rich.table import Table + +from ....core.types import SessionResults + +stats = None +StratifiedTable = None + + +def _ensure_compare_deps() -> None: + global stats, StratifiedTable + if stats is None or StratifiedTable is None: + try: + from scipy import stats as _stats + from statsmodels.stats.contingency_tables import StratifiedTable as _StratifiedTable + except ImportError as exc: + raise click.ClickException( + "Compare commands require the optional analysis dependencies. " + "Install them with `pip install 'exgentic[analysis]'`." + ) from exc + + stats = _stats + StratifiedTable = _StratifiedTable + + +class TaskInfo(NamedTuple): + """Task information including session result, setup info, and file path.""" + + session_result: SessionResults + setup_info: str # Agent name or model name when comparing across agents/models + file_path: str # Path to the results.json file + + +class BenchmarkStats(NamedTuple): + """Statistics for a single benchmark comparison between two setups.""" + + benchmark_name: str + setup1_label: str + setup2_label: str + success_rate1: float + success_rate2: float + rate_diff: float + p_value: float + num_tasks: int + is_significant: bool + winner: str + success1: list[bool] # For Breslow-Day test + success2: list[bool] # For Breslow-Day test + breslow_day_pvalue: Optional[float] = 0 # Breslow-Day test p-value (if multiple benchmarks) + breslow_day_interpretation: Optional[str] = "" # Breslow-Day test interpretation + + +class PairwiseComparison(NamedTuple): + """Complete pairwise comparison data between two setups.""" + + setup1_label: str + setup2_label: str + per_benchmark: dict[str, BenchmarkStats] # benchmark_name -> stats + aggregate_stats: Optional[BenchmarkStats] # Overall stats across benchmarks + + +# Type alias for composite key: either a string (task_id) or tuple (task_id, setup_info) +CompositeKey = Union[str, Tuple[str, str]] + + +def _parse_benchmark_spec(benchmark_spec: str) -> tuple[str, str | None, int | None]: + """Parse benchmark specification in format 'benchmark', 'benchmark/subset', or 'benchmark/subset=limit'. + + Examples: + 'tau' -> ('tau', None, None) + 'tau/retail' -> ('tau', 'retail', None) + 'tau/retail=30' -> ('tau', 'retail', 30) + 'gsm8k=100' -> ('gsm8k', None, 100) + + Returns: + Tuple of (benchmark_name, subset_name or None, limit or None) + """ + # Check for limit parameter (=N) + limit = None + if "=" in benchmark_spec: + benchmark_spec, limit_str = benchmark_spec.rsplit("=", 1) + try: + limit = int(limit_str) + except ValueError: + raise click.ClickException( + f"Invalid limit value in benchmark spec: {limit_str}. Must be an integer." + ) from None + + # Check for subset (/subset) + if "/" in benchmark_spec: + parts = benchmark_spec.split("/", 1) + return parts[0], parts[1], limit + return benchmark_spec, None, limit + + +def _load_run_results( + output_dir: str, + agent: str | None, + model: str | None, + benchmark: str, + subset: str | None = None, + agent_kwargs: dict[str, Any] | None = None, + benchmark_kwargs: dict[str, Any] | None = None, + progress: Progress | None = None, + task_id: Any = None, +) -> dict[CompositeKey, TaskInfo] | None: + """Load run results for a specific agent/model/benchmark combination by searching config.json files. + + If agent is None, loads results from all agents and stores agent name in setup_info. + If model is None, loads results from all models and stores model name in setup_info. + + Args: + output_dir: Directory containing run outputs + agent: Agent name to filter by, or None for all agents + model: Model name to filter by, or None for all models + benchmark: Benchmark name to search for + subset: Optional benchmark subset + agent_kwargs: Optional agent configuration parameters + benchmark_kwargs: Optional benchmark configuration parameters + progress: Optional Rich Progress instance for showing progress + task_id: Optional task ID for updating progress + + Returns: + Dict mapping composite_key (task_id or (task_id, setup_info)) -> TaskInfo, or None if not found + """ + output_path = Path(output_dir) + results_by_task: dict[CompositeKey, TaskInfo] = {} + + # Collect all config files first to show progress + config_files = list(output_path.glob("**/config.json")) + + # Search through all config.json files in the output directory + for idx, config_file in enumerate(config_files): + if progress and task_id is not None: + progress.update(task_id, completed=idx, total=len(config_files)) + try: + with open(config_file, encoding="utf-8") as f: + config_data = json.load(f) + + # Handle two different config.json formats: + # 1. Direct format: {"benchmark": "...", "agent": "...", ...} + # 2. Nested format: {"benchmark": {"slug_name": "..."}, "agent": {"slug_name": "..."}, ...} + + # Extract benchmark name + config_benchmark = config_data.get("benchmark") + if isinstance(config_benchmark, dict): + config_benchmark = config_benchmark.get("slug_name") + + # Extract agent name + config_agent = config_data.get("agent") + if isinstance(config_agent, dict): + config_agent = config_agent.get("slug_name") + + # Extract model name + config_model = config_data.get("model") + if config_model is None and isinstance(config_data.get("agent"), dict): + config_model = config_data.get("agent", {}).get("model_name") + + # Extract subset name + config_subset = config_data.get("subset") + if config_subset is None and isinstance(config_data.get("benchmark"), dict): + config_subset = config_data.get("benchmark", {}).get("params", {}).get("subset") + + # Check if this config file matches our criteria + # Check benchmark + if config_benchmark != benchmark: + continue + + # Check agent if specified + if agent is not None: + if config_agent != agent: + continue + + # Check model if specified + if model is not None: + if config_model is None: + continue + # Normalize model names for comparison (handle different formats) + if model not in config_model and config_model not in model: + continue + + # Check subset if specified + if subset is not None: + if config_subset != subset: + continue + + # If we get here, this is a match + if progress and task_id is not None: + progress.update(task_id, description=f"Loading from {config_file.parent.name}...") + + # Determine the run directory based on config location + # Config can be at: outputs/config.json or outputs/{run_id}/run/config.json + if config_file.name == "config.json" and config_file.parent.name == "run": + run_dir = config_file.parent.parent + elif config_file.parent == output_path: + # Top-level config.json, need to find the actual run directory + run_id = config_data.get("run_id") + if run_id: + run_dir = output_path / run_id + else: + continue + else: + # Assume config is in the run directory + run_dir = config_file.parent + + sessions_dir = run_dir / "sessions" + + if not sessions_dir.exists(): + continue + + # Iterate through all session directories + for session_dir in sessions_dir.iterdir(): + if not session_dir.is_dir(): + continue + + session_results_file = session_dir / "results.json" + if not session_results_file.exists(): + continue + + try: + with open(session_results_file, encoding="utf-8") as f: + session_data = json.load(f) + + # Validate session result and ensure success is consistent with score + session_result = SessionResults.model_validate(session_data) + + # Check if success field is consistent with score (success should be True iff score == 1) + expected_success = session_result.score == 1.0 + if session_result.success != expected_success: + # console = Console() + # console.print( + # f"[yellow]Warning: Inconsistent success field in {session_results_file}[/yellow]\n" + # f" Session ID: {session_result.session_id}\n" + # f" Score: {session_result.score}\n" + # f" Success field: {session_result.success}\n" + # f" Expected success: {expected_success}\n" + # f" Correcting success to match score..." + # ) + # Correct the success field to match the score + session_result.success = expected_success + session_task_id = session_result.task_id + if session_task_id == "" or session_task_id is None: + raise ValueError( + f"Illegal task key '{session_task_id}'!\n" f" File: {session_results_file}\n" + ) + + # Determine setup info based on what's being compared + setup_info = "" + if agent is None and config_agent: + setup_info = config_agent + elif model is None and config_model: + setup_info = config_model + + # Create composite key: (task_id, setup_info) for unique identification + # When setup_info is empty (specific agent/model), just use task_id + composite_key = (session_task_id, setup_info) if setup_info else session_task_id + + # Check for duplicate composite keys + if composite_key in results_by_task: + existing_task_info = results_by_task[composite_key] + if existing_task_info.session_result.success != session_result.success: + raise ValueError( + f"Duplicate entry found for task '{session_task_id}' with setup '{setup_info}'!\n" + f" First occurrence:\n" + f" File: {existing_task_info.file_path}\n" + f" Session ID: {existing_task_info.session_result.session_id}\n" + f" Success: {existing_task_info.session_result.success}\n" + f" Second occurrence:\n" + f" File: {session_results_file}\n" + f" Session ID: {session_result.session_id}\n" + f" Success: {session_result.success}\n" + f" This indicates overlapping results." + ) + + task_info = TaskInfo( + session_result=session_result, + setup_info=setup_info, + file_path=str(session_results_file), + ) + results_by_task[composite_key] = task_info + # print(f"Loaded session {session_result.session_id} with composite_key {composite_key} " + # f"with score {session_result.score} from file {session_results_file}.") + + except Exception as e: + # Print error but continue loading other sessions + console = Console() + console.print(f"[yellow]Warning: Error loading {session_results_file}: {e}[/yellow]") + continue + + # When agent or model is None, continue searching for more runs + # When both are specified, return after first match + # if config_agent is not None and config_model is not None and results_by_task: + # if progress and task_id is not None: + # progress.update(task_id, completed=len(config_files), + # description=f"Loaded {len(results_by_task)} sessions") + # return results_by_task + + except Exception as e: + # Print error but continue with other config files + console = Console() + console.print(f"[yellow]Warning: Error reading {config_file}: {e}[/yellow]") + continue + + # Complete progress + if progress and task_id is not None: + progress.update( + task_id, + completed=len(config_files), + description=f"Loaded {len(results_by_task)} sessions", + ) + + # Return accumulated results (for agent=None or model=None case) or None if nothing found + return results_by_task if results_by_task else None + + +def _calculate_benchmark_stats( + benchmark_name: str, + setup1_label: str, + setup2_label: str, + setup1_results: dict[CompositeKey, TaskInfo], + setup2_results: dict[CompositeKey, TaskInfo], + limit: int | None = None, +) -> Optional[BenchmarkStats]: + """Calculate statistics for comparing two setups on a single benchmark. + + Args: + benchmark_name: Name of the benchmark + setup1_label: Label for first setup + setup2_label: Label for second setup + setup1_results: Results for first setup + setup2_results: Results for second setup + limit: Optional limit on number of tasks to compare + + Returns: + BenchmarkStats object or None if no valid comparisons + """ + # Get comparison data + benchmark_data = _compare_results(benchmark_name, setup1_results, setup2_results) + + # Extract valid comparisons (tasks present in both setups) + valid_comparisons = [(s1, s2) for _, _, _, _, s1, s2 in benchmark_data if s1 is not None and s2 is not None] + + # Apply limit if specified + if limit is not None and len(valid_comparisons) > limit: + valid_comparisons = valid_comparisons[:limit] + + if not valid_comparisons: + return None + + # Extract success lists + success1 = [s1 for s1, _ in valid_comparisons] + success2 = [s2 for _, s2 in valid_comparisons] + + # Calculate rates + success_rate1 = sum(success1) / len(success1) + success_rate2 = sum(success2) / len(success2) + num_tasks = len(valid_comparisons) + rate_diff = success_rate1 - success_rate2 + + # Compute statistical significance + ( + statistic, + p_value, + significance, + method, + n01, + n10, + ) = _compute_statistical_significance_mcnemar(success1, success2) + + is_significant = p_value < 0.05 + winner = setup1_label if rate_diff > 0 else setup2_label if rate_diff < 0 else "Tie" + + return BenchmarkStats( + benchmark_name=benchmark_name, + setup1_label=setup1_label, + setup2_label=setup2_label, + success_rate1=success_rate1, + success_rate2=success_rate2, + rate_diff=rate_diff, + p_value=p_value, + num_tasks=num_tasks, + is_significant=is_significant, + winner=winner, + success1=success1, + success2=success2, + ) + + +def _calculate_aggregate_stats( + setup1_label: str, + setup2_label: str, + per_benchmark_stats: list[BenchmarkStats], +) -> Optional[BenchmarkStats]: + """Calculate aggregate statistics across multiple benchmarks. + + Args: + setup1_label: Label for first setup + setup2_label: Label for second setup + per_benchmark_stats: List of BenchmarkStats from individual benchmarks + + Returns: + BenchmarkStats object with aggregated data or None if no data + """ + if not per_benchmark_stats: + return None + + # Aggregate success lists from all benchmarks + all_success1 = [] + all_success2 = [] + for bench_stats in per_benchmark_stats: + all_success1.extend(bench_stats.success1) + all_success2.extend(bench_stats.success2) + + if not all_success1: + return None + + # Calculate aggregate rates + success_rate1 = sum(all_success1) / len(all_success1) + success_rate2 = sum(all_success2) / len(all_success2) + num_tasks = len(all_success1) + rate_diff = success_rate1 - success_rate2 + + # Compute statistical significance + ( + statistic, + p_value, + significance, + method, + n01, + n10, + ) = _compute_statistical_significance_mcnemar(all_success1, all_success2) + + is_significant = p_value < 0.05 + winner = setup1_label if rate_diff > 0 else setup2_label if rate_diff < 0 else "Tie" + + if len(per_benchmark_stats) > 1: + bd_data = [(bench_stats.success1, bench_stats.success2) for bench_stats in per_benchmark_stats] + bd_p_value, bd_interp = _compute_breslow_day_test(bd_data) + else: + bd_p_value, bd_interp = None, None + return BenchmarkStats( + benchmark_name="Overall", + setup1_label=setup1_label, + setup2_label=setup2_label, + success_rate1=success_rate1, + success_rate2=success_rate2, + rate_diff=rate_diff, + p_value=p_value, + num_tasks=num_tasks, + is_significant=is_significant, + winner=winner, + success1=all_success1, + success2=all_success2, + breslow_day_pvalue=bd_p_value, + breslow_day_interpretation=bd_interp, + ) + + +def _compare_results( + benchmark: str, + setup1_results: dict[CompositeKey, TaskInfo] | None, + setup2_results: dict[CompositeKey, TaskInfo] | None, +) -> list[tuple[str, str, str, str, bool | None, bool | None]]: + """Compare results from two setups for a single benchmark. + + Compares results by matching composite keys directly. For cross-agent/model comparisons, + this means comparing (task_id, setup_info) pairs. + + Returns: + List of (benchmark, task_id, setup1_info, setup2_info, setup1_success, setup2_success) + """ + comparison_data = [] + + if setup1_results is None and setup2_results is None: + return comparison_data + + setup1_tasks = setup1_results or {} + setup2_tasks = setup2_results or {} + + # Get all composite keys from both setups + all_keys = set(setup1_tasks.keys()) | set(setup2_tasks.keys()) + + # Remove None or empty keys + all_keys = {k for k in all_keys if k is not None and k != ""} + + for composite_key in sorted(all_keys): + # Extract task_id from composite key + task_id = composite_key[0] if isinstance(composite_key, tuple) else composite_key + + setup1_task_info = setup1_tasks.get(composite_key) + setup2_task_info = setup2_tasks.get(composite_key) + + setup1_success = setup1_task_info.session_result.success if setup1_task_info else None + setup2_success = setup2_task_info.session_result.success if setup2_task_info else None + setup1_info = setup1_task_info.setup_info if setup1_task_info else "" + setup2_info = setup2_task_info.setup_info if setup2_task_info else "" + + comparison_data.append( + ( + benchmark, + task_id, + setup1_info, + setup2_info, + setup1_success, + setup2_success, + ) + ) + + return comparison_data + + +def _compute_statistical_significance_mcnemar( + success1: list[bool], + success2: list[bool], +) -> tuple[float, float, str, str, int, int]: + """Compute statistical significance using McNemar's test for binary outcomes. + + McNemar's test is appropriate for comparing two classifiers on the same test set. + It tests whether the disagreements between the two classifiers are systematic. + + Args: + success1: Success outcomes from setup 1 (list of booleans) + success2: Success outcomes from setup 2 (list of booleans) + + Returns: + Tuple of (statistic, p_value, interpretation, method_name, n01, n10) + """ + if len(success1) < 2 or len(success2) < 2: + return 0.0, 1.0, "insufficient data", "McNemar's test", 0, 0 + + if len(success1) != len(success2): + return 0.0, 1.0, "mismatched sample sizes", "McNemar's test", 0, 0 + + # Build contingency table + # n01: setup1 failed, setup2 succeeded + # n10: setup1 succeeded, setup2 failed + n01 = sum(1 for s1, s2 in zip(success1, success2) if not s1 and s2) + n10 = sum(1 for s1, s2 in zip(success1, success2) if s1 and not s2) + + # McNemar's test statistic + # Use continuity correction for small samples + if n01 + n10 == 0: + return 0.0, 1.0, "no disagreements", "McNemar's test", n01, n10 + + # Chi-square statistic with continuity correction + statistic = ((abs(n01 - n10) - 1) ** 2) / (n01 + n10) + + # p-value from chi-square distribution with 1 degree of freedom + # Convert to native Python float for JSON serialization + p_value = float(1 - stats.chi2.cdf(statistic, df=1)) + + # Interpret the result + if p_value < 0.001: + significance = "highly significant (p < 0.001)" + elif p_value < 0.01: + significance = "very significant (p < 0.01)" + elif p_value < 0.05: + significance = "significant (p < 0.05)" + elif p_value < 0.1: + significance = "marginally significant (p < 0.1)" + else: + significance = "not significant (p >= 0.1)" + + return float(statistic), p_value, significance, "McNemar's test", n01, n10 + + +def _compute_breslow_day_test( + contingency_tables_data: list[tuple[list[bool], list[bool]]], +) -> tuple[float, str]: + """Compute Breslow-Day test for homogeneity of odds ratios across strata (benchmarks). + + The Breslow-Day test checks whether the odds ratios are consistent across different + benchmarks. A significant result suggests that the effect of one setup vs another + varies across benchmarks. + + Args: + contingency_tables_data: List of (success1, success2) tuples for each benchmark, + where success1/success2 are lists of boolean success values + + Returns: + Tuple of (p_value, interpretation) + """ + if len(contingency_tables_data) < 2: + return 1.0, "insufficient benchmarks (need at least 2)" + + # Build 2x2xK contingency table where K is the number of benchmarks + tables = [] + for success1, success2 in contingency_tables_data: + if len(success1) != len(success2): + continue + + # Build 2x2 table for this benchmark + # Rows: Setup 1 (success=1, failure=0) + # Cols: Setup 2 (success=1, failure=0) + n11 = sum(1 for s1, s2 in zip(success1, success2) if s1 and s2) + n10 = sum(1 for s1, s2 in zip(success1, success2) if s1 and not s2) + n01 = sum(1 for s1, s2 in zip(success1, success2) if not s1 and s2) + n00 = sum(1 for s1, s2 in zip(success1, success2) if not s1 and not s2) + + # Create 2x2 table: [[n11, n10], [n01, n00]] + table = [[n11, n10], [n01, n00]] + tables.append(table) + + if len(tables) < 2: + return 1.0, "insufficient valid benchmarks" + + try: + # Create StratifiedTable and run test_equal_odds (Breslow-Day test) + st = StratifiedTable(tables) + result = st.test_equal_odds() + p_value = float(result.pvalue) + + # Interpret the result + if p_value < 0.01: + interpretation = ( + "significant heterogeneity (p < 0.01) - relative success rates varies significantly across benchmarks" + ) + elif p_value < 0.05: + interpretation = "moderate heterogeneity (p < 0.05) - relative success rate vary across benchmarks" + elif p_value < 0.1: + interpretation = "marginal heterogeneity (p < 0.1) - relative success rate somewhat vary across benchmarks" + else: + interpretation = "homogeneous (p >= 0.1) - relative success rate is consistent across benchmarks" + + return p_value, interpretation + except Exception as e: + return 1.0, f"test failed: {e!s}" + + +def _render_significance_matrix( + benchmark_stats_list: list[BenchmarkStats], + title: str = "Statistical Significance Matrix", + benchmark_name: str = "", +) -> None: + """Render a matrix showing p-value significance levels between setups. + + Matrix format: + - Rows: Setup 1 + - Columns: Setup 2 + - Cell values: Significance level + * = p < 0.05 + ** = p < 0.01 + *** = p < 0.001 + - = not significant + + Args: + benchmark_stats_list: List of BenchmarkStats objects + title: Title for the matrix + benchmark_name: Optional benchmark name to include in table title + """ + console = Console() + + # Extract all unique setups and their success rates + setup_rates: dict[str, float] = {} + for bench_stats in benchmark_stats_list: + if bench_stats.setup1_label not in setup_rates: + setup_rates[bench_stats.setup1_label] = bench_stats.success_rate1 + if bench_stats.setup2_label not in setup_rates: + setup_rates[bench_stats.setup2_label] = bench_stats.success_rate2 + + # Sort setups by success rate (descending - best first) + sorted_setups = sorted(setup_rates.keys(), key=lambda s: setup_rates[s], reverse=True) + + # Build p-value matrix and winner matrix + # Key: (setup1, setup2), Value: (p_value, winner) + comparison_data: dict[tuple[str, str], tuple[float, str]] = {} + + for bench_stats in benchmark_stats_list: + comparison_data[(bench_stats.setup1_label, bench_stats.setup2_label)] = ( + bench_stats.p_value, + bench_stats.winner if bench_stats.is_significant else "", + ) + + # Create the matrix table + console.print(f"\n[bold cyan]{title}[/bold cyan]") + console.print("Significance levels: *** p<0.001, ** p<0.01, * p<0.05, - not significant\n") + + # Include benchmark name in table title if provided + table_title = f"P-value Significance Matrix: {benchmark_name}" if benchmark_name else "P-value Significance Matrix" + table = Table(show_header=True, show_lines=True, title=table_title) + + # Add header row + table.add_column("Setup", style="cyan", no_wrap=True) + for setup in sorted_setups: + table.add_column(setup, style="white", justify="center", no_wrap=True) + + # Add data rows + for row_setup in sorted_setups: + row_data = [row_setup] + for col_setup in sorted_setups: + if row_setup == col_setup: + # Diagonal: same setup + cell = "[dim]-[/dim]" + else: + # Look up comparison data for this pair + data = comparison_data.get((row_setup, col_setup)) + + if data is None: + # Try reverse lookup (col_setup vs row_setup) + data = comparison_data.get((col_setup, row_setup)) + if data is not None: + # Reverse the winner perspective + p_value, winner = data + # Winner stays the same, but we interpret from row's perspective + else: + p_value, winner = None, "" + else: + p_value, winner = data + + if p_value is None: + cell = "[dim]N/A[/dim]" + elif p_value < 0.001: + if winner == row_setup: + cell = "[green]***[/green]" + elif winner == col_setup: + cell = "[bright_red]***[/bright_red]" + else: + cell = "***" + elif p_value < 0.01: + if winner == row_setup: + cell = "[green]**[/green]" + elif winner == col_setup: + cell = "[bright_red]**[/bright_red]" + else: + cell = "**" + elif p_value < 0.05: + if winner == row_setup: + cell = "[green]*[/green]" + elif winner == col_setup: + cell = "[bright_red]*[/bright_red]" + else: + cell = "*" + else: + cell = "[dim]-[/dim]" + + row_data.append(cell) + + table.add_row(*row_data) + + console.print(table) + console.print("\n[dim]Green: row setup is significantly better | Red: column setup is significantly better[/dim]") + + +def _render_pairwise_summary_table( + benchmark_stats_list: list[BenchmarkStats], + title: str = "Pairwise Comparison Summary", +) -> None: + """Render pairwise comparison summary table showing which setups are significantly better. + + Args: + benchmark_stats_list: List of BenchmarkStats objects + title: Title for the table + """ + console = Console() + + console.print(f"\n[bold cyan]{title}[/bold cyan]") + console.print("Shows which setups are statistically significantly better than others (p < 0.05)\n") + + # First, render the significance matrix + # Extract benchmark name from title if present (format: "Comparison Results: benchmark_name") + benchmark_name = title.split(": ", 1)[1] if ": " in title else "" + _render_significance_matrix( + benchmark_stats_list, + title="Statistical Significance Matrix", + benchmark_name=benchmark_name, + ) + + # Sort by Success Rate 1 (descending), then by Success Rate 2 (descending) + sorted_results = sorted(benchmark_stats_list, key=lambda x: (-x.success_rate1, -x.success_rate2)) + + # Include benchmark name in detailed table title if present + detailed_table_title = f"Detailed Comparison: {benchmark_name}" if benchmark_name else "Detailed Comparison" + table = Table(title=detailed_table_title, show_lines=True) + table.add_column("Setup 1", style="cyan", no_wrap=True) + table.add_column("Setup 2", style="magenta", no_wrap=True) + table.add_column("Success Rate 1", style="green", justify="right") + table.add_column("Success Rate 2", style="blue", justify="right") + table.add_column("Rate Diff", style="yellow", justify="right") + table.add_column("# Tasks", style="white", justify="right") + table.add_column("p-value", style="white", justify="right") + table.add_column("Significant?", style="white", justify="center") + table.add_column("Winner", style="green", no_wrap=True) + table.add_column("Breslow-Day", style="white", justify="center") + + for bench_stats in sorted_results: + rate1_str = f"{bench_stats.success_rate1:.1%}" + rate2_str = f"{bench_stats.success_rate2:.1%}" + + diff_str = f"{bench_stats.rate_diff:+.1%}" + if bench_stats.rate_diff > 0: + diff_str = f"[green]{diff_str}[/green]" + elif bench_stats.rate_diff < 0: + diff_str = f"[bright_red]{diff_str}[/bright_red]" + + p_value_str = f"{bench_stats.p_value:.4f}" + + if bench_stats.is_significant: + sig_str = "[green]✓ Yes[/green]" + winner_str = f"[green]{bench_stats.winner}[/green]" + else: + sig_str = "[yellow]○ No[/yellow]" + winner_str = "[dim]No difference[/dim]" + + row_data = [ + bench_stats.setup1_label, + bench_stats.setup2_label, + rate1_str, + rate2_str, + diff_str, + str(bench_stats.num_tasks), + p_value_str, + sig_str, + winner_str, + ] + + if bench_stats.breslow_day_interpretation is not None and bench_stats.breslow_day_pvalue is not None: + if bench_stats.breslow_day_pvalue < 0.05: + bd_str = f"[yellow]p={bench_stats.breslow_day_pvalue:.3f}[/yellow]" + else: + bd_str = f"[green]p={bench_stats.breslow_day_pvalue:.3f}[/green]" + bd_str += f"\n{bench_stats.breslow_day_interpretation}" + row_data.append(bd_str) + + table.add_row(*row_data) + + console.print(table) + + +@click.command("compare") +@click.option("--agent1", help="First agent slug name (optional if comparing across agents)") +@click.option("--agent2", help="Second agent slug name (optional if comparing across agents)") +@click.option("--agent3", help="Third agent slug name (optional)") +@click.option("--agent4", help="Fourth agent slug name (optional)") +@click.option("--agent5", help="Fifth agent slug name (optional)") +@click.option("--model1", help="Model for first agent (optional)") +@click.option("--model2", help="Model for second agent (optional)") +@click.option("--model3", help="Model for third agent (optional)") +@click.option("--model4", help="Model for fourth agent (optional)") +@click.option("--model5", help="Model for fifth agent (optional)") +@click.option( + "--benchmark", + "benchmarks", + multiple=True, + required=True, + help="Benchmark(s) to compare in format 'benchmark', 'benchmark/subset', or 'benchmark/subset=limit' " + "(e.g., tau2/airline, gsm8k, tau2/retail=30 to limit to 30 instances)", +) +@click.option( + "--output-dir", + default="./outputs", + show_default=True, + help="Output directory to search for results", +) +@click.option( + "--format", + "output_format", + type=click.Choice(["text", "json"]), + default="text", + help="Output format", +) +def compare_cmd( + agent1: str | None, + agent2: str | None, + agent3: str | None, + agent4: str | None, + agent5: str | None, + model1: str | None, + model2: str | None, + model3: str | None, + model4: str | None, + model5: str | None, + benchmarks: tuple[str, ...], + output_dir: str, + output_format: str, +) -> None: + r"""Compare performance of multiple agent/model combinations across benchmarks. + + This command searches the output directory to locate results for each agent/model/benchmark combination. + + Benchmarks can be specified with optional subsets using the format 'benchmark/subset'. + + When comparing 2 setups: performs direct comparison. + When comparing 3+ setups: performs pairwise comparisons and shows significance matrix. + + Examples: + # Compare two agents and model configuration on a specific benchmark + exgentic compare --agent1 tool_calling --model1 openai/gcp/gemini-3-pro-preview \\ + --agent2 claude_code --model1 openai/aws/claude-opus-4-5 + + # Compare multiple agents (pairwise) + exgentic compare --agent1 tool_calling --agent2 claude_code --agent3 openai_solo \\ + --benchmark gsm8k + + # Compare multiple models + exgentic compare --model1 openai/aws/claude-opus-4-5 --model2 openai/gcp/gemini-3-pro-preview \\ + --benchmark tau2/airline + + # Compare up to 5 different setups + exgentic compare --agent1 tool_calling --model1 openai/aws/claude-opus-4-5t \\ + --agent2 claude_code --model2 openai/gcp/gemini-3-pro-preview \\ + --agent3 openai_sologemini --model3 openai/gcp/gemini-3-pro-preview \\ + --benchmark gsm8k --benchmark tau2/airline + """ + _ensure_compare_deps() + + if not benchmarks: + raise click.ClickException("At least one --benchmark is required.") + + # Collect all agent/model pairs + agents = [agent1, agent2, agent3, agent4, agent5] + models = [model1, model2, model3, model4, model5] + + # Filter out None values and create setup list + setups = [] + for i, (agent, model) in enumerate(zip(agents, models), 1): + if agent is not None or model is not None: + setups.append((i, agent, model)) + + if len(setups) < 2: + raise click.ClickException("At least 2 agent/model setups are required for comparison.") + + if len(setups) > 5: + raise click.ClickException("Maximum 5 agent/model setups are supported.") + + # Validate input consistency: must be one of three patterns + # 1. All agent/model pairs defined (both agent and model for each setup) + # 2. Only agents defined (all setups have agent, no models) + # 3. Only models defined (all setups have model, no agents) + + has_agents = [agent is not None for _, agent, _ in setups] + has_models = [model is not None for _, _, model in setups] + + all_have_agents = all(has_agents) + all_have_models = all(has_models) + none_have_agents = not any(has_agents) + none_have_models = not any(has_models) + + # Valid patterns: + # 1. All have both agent and model + # 2. All have agent, none have model + # 3. None have agent, all have model + + if not ( + (all_have_agents and all_have_models) + or (all_have_agents and none_have_models) + or (none_have_agents and all_have_models) + ): + raise click.ClickException( + "Invalid input combination. Must use one of these patterns:\n" + " 1. All setups with both agent and model (e.g., --agent1 X --model1 Y --agent2 A --model2 B)\n" + " 2. All setups with only agents (e.g., --agent1 X --agent2 Y --agent3 Z)\n" + " 3. All setups with only models (e.g., --model1 X --model2 Y --model3 Z)" + ) + + # Create labels for each setup + setup_labels = [] + for idx, agent, model in setups: + label = (f"{agent}" if agent else "") + (" and " if agent and model else "") + (f"{model}" if model else "") + if not label: + label = f"Setup {idx}" + setup_labels.append(label) + + # Load results for all setups across all benchmarks with progress bar + all_setup_results = [] + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + TaskProgressColumn(), + console=Console(), + ) as progress: + for setup_idx, (_idx, agent, model) in enumerate(setups): + setup_label = setup_labels[setup_idx] + setup_results = {} + + for benchmark_spec in benchmarks: + benchmark_name, subset, limit = _parse_benchmark_spec(benchmark_spec) + display_name = benchmark_spec + + # Create progress task for this load operation + task_id = progress.add_task(f"Loading {setup_label} on {display_name}...", total=100) + + results = _load_run_results( + output_dir, + agent, + model, + benchmark_name, + subset, + progress=progress, + task_id=task_id, + ) + + # Remove the task after completion + progress.remove_task(task_id) + + if results is None: + eval_cmd = "exgentic evaluate" + if agent: + eval_cmd += f" --agent {agent}" + if model: + eval_cmd += f" --model {model}" + eval_cmd += f" --benchmark {benchmark_name}" + if subset: + eval_cmd += f" --subset {subset}" + + raise click.ClickException( + f"No results found for {setup_label} on '{benchmark_spec}'. " f"Run '{eval_cmd}' first." + ) + + setup_results[display_name] = results + + all_setup_results.append(setup_results) + + # Calculate all pairwise comparisons using new calculation functions + all_pairwise_comparisons: list[PairwiseComparison] = [] + + # Compare all pairs (i, j) where i < j + for i in range(len(setups)): + for j in range(i + 1, len(setups)): + setup1_label = setup_labels[i] + setup2_label = setup_labels[j] + + # Calculate statistics for each benchmark + per_benchmark_stats: dict[str, BenchmarkStats] = {} + + for benchmark_spec in benchmarks: + benchmark_name, subset, limit = _parse_benchmark_spec(benchmark_spec) + display_name = benchmark_spec + + setup1_results = all_setup_results[i][display_name] + setup2_results = all_setup_results[j][display_name] + + # Calculate stats for this benchmark with limit + bench_stats = _calculate_benchmark_stats( + display_name, + setup1_label, + setup2_label, + setup1_results, + setup2_results, + limit=limit, + ) + + if bench_stats: + per_benchmark_stats[display_name] = bench_stats + + # Calculate aggregate statistics across all benchmarks + aggregate_stats = _calculate_aggregate_stats(setup1_label, setup2_label, list(per_benchmark_stats.values())) + + # Store pairwise comparison + all_pairwise_comparisons.append( + PairwiseComparison( + setup1_label=setup1_label, + setup2_label=setup2_label, + per_benchmark=per_benchmark_stats, + aggregate_stats=aggregate_stats, + ) + ) + + # Render results + if output_format == "text": + # Render per-benchmark tables + for benchmark_spec in benchmarks: + # Collect stats for this benchmark from all pairwise comparisons + benchmark_stats_for_display = [] + for comparison in all_pairwise_comparisons: + if benchmark_spec in comparison.per_benchmark: + benchmark_stats_for_display.append(comparison.per_benchmark[benchmark_spec]) + if benchmark_stats_for_display: + _render_pairwise_summary_table( + benchmark_stats_for_display, + title=f"Comparison Results: {benchmark_spec}", + ) + + # Render overall table with Breslow-Day test (if multiple benchmarks) + if len(benchmarks) > 1: + # Render each pairwise comparison with its own Breslow-Day result + aggregate_stats_list = [] + for comparison in all_pairwise_comparisons: + if comparison.aggregate_stats: + aggregate_stats_list.append(comparison.aggregate_stats) + + _render_pairwise_summary_table( + aggregate_stats_list, + title="Overall Comparison (All Benchmarks)", + ) + + else: + # JSON output + output = { + "setups": setup_labels, + "per_benchmark": {}, + "overall": {"pairwise_comparisons": []}, + } + + # Add per-benchmark results from all pairwise comparisons + for benchmark_spec in benchmarks: + benchmark_results = [] + for comparison in all_pairwise_comparisons: + if benchmark_spec in comparison.per_benchmark: + stats = comparison.per_benchmark[benchmark_spec] + benchmark_results.append( + { + "setup1": stats.setup1_label, + "setup2": stats.setup2_label, + "success_rate1": float(stats.success_rate1), + "success_rate2": float(stats.success_rate2), + "rate_difference": float(stats.rate_diff), + "p_value": float(stats.p_value), + "num_tasks": int(stats.num_tasks), + "is_significant": bool(stats.is_significant), + "winner": stats.winner, + } + ) + if benchmark_results: + output["per_benchmark"][benchmark_spec] = benchmark_results + + # Add overall results with Breslow-Day + for comparison in all_pairwise_comparisons: + if comparison.aggregate_stats: + stats = comparison.aggregate_stats + # Get Breslow-Day result from comparison object + if ( + comparison.aggregate_stats.breslow_day_pvalue is not None + and comparison.aggregate_stats.breslow_day_interpretation is not None + ): + breslow_day = { + "p_value": float(comparison.aggregate_stats.breslow_day_pvalue), + "interpretation": comparison.aggregate_stats.breslow_day_interpretation, + } + else: + breslow_day = None + + output["overall"]["pairwise_comparisons"].append( + { + "setup1": stats.setup1_label, + "setup2": stats.setup2_label, + "success_rate1": float(stats.success_rate1), + "success_rate2": float(stats.success_rate2), + "rate_difference": float(stats.rate_diff), + "p_value": float(stats.p_value), + "num_tasks": int(stats.num_tasks), + "is_significant": bool(stats.is_significant), + "winner": stats.winner, + "breslow_day": breslow_day, + } + ) + + print(json.dumps(output, indent=2)) + + +__all__ = ["compare_cmd"] diff --git a/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/commands/dashboard.py b/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/commands/dashboard.py new file mode 100644 index 00000000..ad2a0206 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/commands/dashboard.py @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import rich_click as click + +from ..options import apply_debug_mode + + +@click.command("dashboard") +@click.option( + "--debug", + is_flag=True, + help="Enable debug mode (sets settings.debug=true and log level to DEBUG)", +) +def dashboard_cmd(debug: bool) -> None: + """Start the experiments graphical dashboard.""" + apply_debug_mode(debug) + from ...dashboard.app import main as dashboard_main + + dashboard_main() + + +__all__ = ["dashboard_cmd"] diff --git a/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/commands/evaluate.py b/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/commands/evaluate.py new file mode 100644 index 00000000..5ebb59bd --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/commands/evaluate.py @@ -0,0 +1,429 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import json + +import rich_click as click + +from ....core.types import RunConfig, SessionConfig +from ....utils.settings import get_settings +from ...lib.api import ( + aggregate, + evaluate, + execute, +) +from ..options import add_run_options, has_run_options, run_with + + +def _is_isolated_runner(set_values: tuple[str, ...]) -> bool: + """Check if the runner is docker or venv (via --set or global settings).""" + isolated = {"docker", "venv"} + for item in set_values: + if "=" not in item: + continue + key, val = item.split("=", 1) + if key in ("benchmark.runner", "agent.runner", "settings.default_runner") and val.strip("\"'") in isolated: + return True + return get_settings().default_runner in isolated + + +def _get_runner_from_set(set_values: tuple[str, ...]) -> str | None: + """Extract the runner name from --set values, if specified.""" + for item in set_values: + if "=" not in item: + continue + key, val = item.split("=", 1) + if key in ("benchmark.runner", "agent.runner", "settings.default_runner"): + return val.strip("\"'") + return None + + +def _get_registry_entry(slug: str, kind: str): + """Look up a RegistryEntry for the given slug and kind ('benchmark' or 'agent').""" + from ...registry import AGENTS, BENCHMARKS + + registry = BENCHMARKS if kind == "benchmark" else AGENTS + entry = registry.get(slug) + if entry is None: + raise click.ClickException(f"Unknown {kind} slug '{slug}'") + return entry + + +def _needs_setup(name: str, install_type: str) -> bool: + """Check if a benchmark/agent has a setup.sh or requirements.txt.""" + from ...lib.api import needs_setup + + return needs_setup(name, install_type) + + +def _ensure_installed( + benchmark: str, + agent: str, + set_values: tuple[str, ...], +) -> None: + """Ensure benchmark/agent dependencies and data are installed. + + Env type is determined by the runner from --set flags or settings: + - docker -> DOCKER + - venv (default) -> VENV + - anything else (direct, thread, etc.) -> LOCAL + + For isolated runners (docker/venv), setup runs automatically without prompting. + For other runners, the user is prompted to confirm. + """ + from ....environment import EnvType + from ....environment.instance import get_manager + + mgr = get_manager() + runner = _get_runner_from_set(set_values) or get_settings().default_runner + if runner == "docker": + env_type = EnvType.DOCKER + elif runner == "venv": + env_type = EnvType.VENV + else: + env_type = EnvType.LOCAL + + to_install: list[tuple[str, str, str]] = [] + bench_name = f"benchmarks/{benchmark}" + agent_name = f"agents/{agent}" + + if not mgr.is_installed(bench_name, env_type=env_type) and _needs_setup(benchmark, "benchmark"): + to_install.append(("benchmark", benchmark, bench_name)) + if not mgr.is_installed(agent_name, env_type=env_type) and _needs_setup(agent, "agent"): + to_install.append(("agent", agent, agent_name)) + + if not to_install: + return + + if not _is_isolated_runner(set_values): + names = ", ".join(f"{t} '{n}'" for t, n, _ in to_install) + if not click.confirm(f"{names} not set up. Install now?", default=True): + raise click.Abort() + + for install_type, slug, name in to_install: + entry = _get_registry_entry(slug, install_type) + kwargs: dict = {"env_type": env_type, "module_path": entry.module} + if env_type in (EnvType.VENV, EnvType.DOCKER): + from ....environment.helpers import get_exgentic_install_target + + project_root, packages = get_exgentic_install_target() + if project_root is not None: + kwargs["project_root"] = project_root + if packages: + kwargs["packages"] = packages + mgr.install(name, **kwargs) + + +def _load_config_file(path: str) -> dict: + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def _run_config_from_session(session_config: SessionConfig) -> RunConfig: + return RunConfig( + benchmark=session_config.benchmark, + agent=session_config.agent, + subset=session_config.subset, + task_ids=[session_config.task_id], + output_dir=session_config.output_dir, + cache_dir=session_config.cache_dir, + run_id=session_config.run_id, + model=session_config.model, + benchmark_kwargs=session_config.benchmark_kwargs, + agent_kwargs=session_config.agent_kwargs, + ) + + +@click.group("evaluate", invoke_without_command=True) +@add_run_options(required=False) +@click.option("--config", "config_path", help="RunConfig JSON file") +@click.pass_context +def evaluate_cmd( + ctx: click.Context, + benchmark: str | None, + agent: str | None, + agent_json: str | None, + agent_arg: tuple[str, ...], + set_values: tuple[str, ...], + subset: str | None, + tasks: tuple[str, ...], + num_tasks: int | None, + max_steps: int | None, + max_actions: int | None, + model: str | None, + debug: bool, + overwrite: bool, + log_level: str | None, + output_dir: str, + cache_dir: str | None, + run_id: str | None, + max_workers: int | None, + config_path: str | None, +) -> None: + """Run sessions and aggregate results.""" + if config_path: + if ctx.invoked_subcommand is not None: + raise click.ClickException("--config cannot be used with subcommands.") + if has_run_options( + benchmark=benchmark, + agent=agent, + agent_json=agent_json, + agent_arg=agent_arg, + set_values=set_values, + subset=subset, + tasks=tasks, + num_tasks=num_tasks, + max_steps=max_steps, + max_actions=max_actions, + model=model, + debug=debug, + overwrite=overwrite, + log_level=log_level, + output_dir=output_dir, + cache_dir=cache_dir, + run_id=run_id, + max_workers=max_workers, + ): + raise click.ClickException("Do not pass run options together with --config.") + config = RunConfig.model_validate(_load_config_file(config_path)) + evaluate(config) + return + if ctx.invoked_subcommand is not None: + if has_run_options( + benchmark=benchmark, + agent=agent, + agent_json=agent_json, + agent_arg=agent_arg, + set_values=set_values, + subset=subset, + tasks=tasks, + num_tasks=num_tasks, + max_steps=max_steps, + max_actions=max_actions, + model=model, + debug=debug, + overwrite=overwrite, + log_level=log_level, + output_dir=output_dir, + cache_dir=cache_dir, + run_id=run_id, + max_workers=max_workers, + ): + raise click.ClickException( + "Pass options after the subcommand, e.g. 'exgentic evaluate execute --benchmark ...'." + ) + return + if not benchmark or not agent: + raise click.ClickException("--benchmark and --agent are required.") + + _ensure_installed(benchmark, agent, set_values) + + run_with( + evaluate, + benchmark=benchmark, + agent=agent, + agent_json=agent_json, + agent_arg=agent_arg, + set_values=set_values, + subset=subset, + tasks=tasks, + num_tasks=num_tasks, + model=model, + debug=debug, + overwrite=overwrite, + log_level=log_level, + output_dir=output_dir, + cache_dir=cache_dir, + run_id=run_id, + max_workers=max_workers, + max_steps=max_steps, + max_actions=max_actions, + ) + + +@evaluate_cmd.command("execute") +@add_run_options +def evaluate_execute_cmd( + benchmark: str, + agent: str, + agent_json: str | None, + agent_arg: tuple[str, ...], + set_values: tuple[str, ...], + subset: str | None, + tasks: tuple[str, ...], + num_tasks: int | None, + max_steps: int | None, + max_actions: int | None, + model: str | None, + debug: bool, + overwrite: bool, + log_level: str | None, + output_dir: str, + cache_dir: str | None, + run_id: str | None, + max_workers: int | None, +) -> None: + """Run sessions only.""" + _ensure_installed(benchmark, agent, set_values) + run_with( + execute, + benchmark=benchmark, + agent=agent, + agent_json=agent_json, + agent_arg=agent_arg, + set_values=set_values, + subset=subset, + tasks=tasks, + num_tasks=num_tasks, + model=model, + debug=debug, + overwrite=overwrite, + log_level=log_level, + output_dir=output_dir, + cache_dir=cache_dir, + run_id=run_id, + max_workers=max_workers, + max_steps=max_steps, + max_actions=max_actions, + ) + + +@evaluate_cmd.command("aggregate") +@add_run_options +def evaluate_aggregate_cmd( + benchmark: str, + agent: str, + agent_json: str | None, + agent_arg: tuple[str, ...], + set_values: tuple[str, ...], + subset: str | None, + tasks: tuple[str, ...], + num_tasks: int | None, + max_steps: int | None, + max_actions: int | None, + model: str | None, + debug: bool, + overwrite: bool, + log_level: str | None, + output_dir: str, + cache_dir: str | None, + run_id: str | None, + max_workers: int | None, +) -> None: + """Aggregate results from completed sessions.""" + run_with( + aggregate, + benchmark=benchmark, + agent=agent, + agent_json=agent_json, + agent_arg=agent_arg, + set_values=set_values, + subset=subset, + tasks=tasks, + num_tasks=num_tasks, + model=model, + debug=debug, + overwrite=overwrite, + log_level=log_level, + output_dir=output_dir, + cache_dir=cache_dir, + run_id=run_id, + max_workers=max_workers, + max_steps=max_steps, + max_actions=max_actions, + ) + + +@evaluate_cmd.command("session") +@add_run_options(required=False) +@click.option( + "--config", + "session_config_path", + help="SessionConfig JSON file for a single session.", +) +def evaluate_session_cmd( + benchmark: str | None, + agent: str | None, + agent_json: str | None, + agent_arg: tuple[str, ...], + set_values: tuple[str, ...], + subset: str | None, + tasks: tuple[str, ...], + num_tasks: int | None, + max_steps: int | None, + max_actions: int | None, + model: str | None, + debug: bool, + overwrite: bool, + log_level: str | None, + output_dir: str, + cache_dir: str | None, + run_id: str | None, + max_workers: int | None, + session_config_path: str | None, +) -> None: + """Run a single session.""" + if session_config_path: + if has_run_options( + benchmark=benchmark, + agent=agent, + agent_json=agent_json, + agent_arg=agent_arg, + set_values=set_values, + subset=subset, + tasks=tasks, + num_tasks=num_tasks, + max_steps=max_steps, + max_actions=max_actions, + model=model, + debug=debug, + overwrite=overwrite, + log_level=log_level, + output_dir=output_dir, + cache_dir=cache_dir, + run_id=run_id, + max_workers=max_workers, + ): + raise click.ClickException("Do not pass run options together with --config.") + session_config = SessionConfig.model_validate(_load_config_file(session_config_path)) + run_config = _run_config_from_session(session_config) + execute(run_config) + return + if not benchmark or not agent: + raise click.ClickException("--benchmark and --agent are required.") + if num_tasks is not None: + raise click.ClickException("Use --task instead of --num-tasks for sessions.") + if len(tasks) != 1: + raise click.ClickException("Exactly one --task is required for sessions.") + _ensure_installed(benchmark, agent, set_values) + run_with( + execute, + benchmark=benchmark, + agent=agent, + agent_json=agent_json, + agent_arg=agent_arg, + set_values=set_values, + subset=subset, + tasks=tasks, + num_tasks=None, + model=model, + debug=debug, + overwrite=overwrite, + log_level=log_level, + output_dir=output_dir, + cache_dir=cache_dir, + run_id=run_id, + max_workers=max_workers, + max_steps=max_steps, + max_actions=max_actions, + ) + + +__all__ = [ + "evaluate_aggregate_cmd", + "evaluate_cmd", + "evaluate_execute_cmd", + "evaluate_session_cmd", +] diff --git a/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/commands/listing.py b/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/commands/listing.py new file mode 100644 index 00000000..d0d042ed --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/commands/listing.py @@ -0,0 +1,105 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import rich_click as click + +from ...lib.api import list_agents, list_benchmarks, list_subsets, list_tasks +from ..options import apply_debug_mode +from ..render import render_list, render_named_list + + +@click.group("list") +@click.option( + "--debug", + is_flag=True, + help="Enable debug mode (sets settings.debug=true and log level to DEBUG)", +) +def list_cmd(debug: bool) -> None: + """List available resources.""" + apply_debug_mode(debug) + + +@list_cmd.command("benchmarks") +@click.option( + "--debug", + is_flag=True, + help="Enable debug mode (sets settings.debug=true and log level to DEBUG)", +) +@click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text") +def list_benchmarks_cmd(debug: bool, output_format: str) -> None: + """List available benchmarks.""" + apply_debug_mode(debug) + items = list_benchmarks() + render_named_list(items, output_format, title="Benchmarks") + + +@list_cmd.command("agents") +@click.option( + "--debug", + is_flag=True, + help="Enable debug mode (sets settings.debug=true and log level to DEBUG)", +) +@click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text") +def list_agents_cmd(debug: bool, output_format: str) -> None: + """List available agents.""" + apply_debug_mode(debug) + items = list_agents() + render_named_list(items, output_format, title="Agents") + + +@list_cmd.command("subsets") +@click.option( + "--debug", + is_flag=True, + help="Enable debug mode (sets settings.debug=true and log level to DEBUG)", +) +@click.option("--benchmark", required=True, help="Benchmark slug_name") +@click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text") +def list_subsets_cmd(debug: bool, benchmark: str, output_format: str) -> None: + """List available subsets for a benchmark.""" + apply_debug_mode(debug) + try: + subsets = list_subsets(benchmark) + except Exception as exc: + raise click.ClickException(str(exc)) from exc + render_list(subsets, output_format, title=f"Subsets ({benchmark})") + + +@list_cmd.command("tasks") +@click.option( + "--debug", + is_flag=True, + help="Enable debug mode (sets settings.debug=true and log level to DEBUG)", +) +@click.option("--benchmark", required=True, help="Benchmark slug_name") +@click.option("--subset", help="Benchmark subset name") +@click.option("--limit", type=int, help="Limit output to first N tasks") +@click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text") +def list_tasks_cmd( + debug: bool, + benchmark: str, + subset: str | None, + limit: int | None, + output_format: str, +) -> None: + """List task ids for a benchmark.""" + apply_debug_mode(debug) + try: + tasks = list_tasks(benchmark=benchmark, subset=subset) + except Exception as exc: + raise click.ClickException(str(exc)) from exc + if limit is not None: + tasks = tasks[: int(limit)] + label = f"Tasks ({benchmark})" if subset is None else f"Tasks ({benchmark}:{subset})" + render_list(tasks, output_format, title=label) + + +__all__ = [ + "list_cmd", + "list_benchmarks_cmd", + "list_agents_cmd", + "list_subsets_cmd", + "list_tasks_cmd", +] diff --git a/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/commands/run_info.py b/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/commands/run_info.py new file mode 100644 index 00000000..2928f161 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/commands/run_info.py @@ -0,0 +1,212 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import json + +import rich_click as click + +from ....core.types import RunConfig, SessionConfig +from ...lib.api import preview, results, status +from ..options import add_run_options, has_run_options, run_query +from ..render import render_run_plan, render_run_results, render_run_status + + +@click.command("status") +@add_run_options(required=False, include_overwrite=False, include_max_workers=False) +@click.option( + "--config", + "config_path", + help="RunConfig or SessionConfig JSON file", +) +@click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text") +def status_cmd( + benchmark: str | None, + agent: str | None, + agent_json: str | None, + agent_arg: tuple[str, ...], + set_values: tuple[str, ...], + subset: str | None, + tasks: tuple[str, ...], + num_tasks: int | None, + max_steps: int | None, + max_actions: int | None, + model: str | None, + debug: bool, + log_level: str | None, + output_dir: str, + cache_dir: str | None, + run_id: str | None, + config_path: str | None, + output_format: str, +) -> None: + """Show current run status.""" + if config_path: + if has_run_options( + benchmark=benchmark, + agent=agent, + agent_json=agent_json, + agent_arg=agent_arg, + set_values=set_values, + subset=subset, + tasks=tasks, + num_tasks=num_tasks, + max_steps=max_steps, + max_actions=max_actions, + model=model, + debug=debug, + overwrite=False, + log_level=log_level, + output_dir=output_dir, + cache_dir=cache_dir, + run_id=run_id, + max_workers=None, + ): + raise click.ClickException("Do not pass run options together with --config.") + payload = _load_config_file(config_path) + try: + config = RunConfig.model_validate(payload) + except Exception: + session_config = SessionConfig.model_validate(payload) + config = _run_config_from_session(session_config) + render_run_status(status(config), output_format) + return + if not benchmark or not agent: + raise click.ClickException("--benchmark and --agent are required.") + run_query( + run_func=status, + render_func=render_run_status, + output_format=output_format, + benchmark=benchmark, + agent=agent, + agent_json=agent_json, + agent_arg=agent_arg, + set_values=set_values, + subset=subset, + tasks=tasks, + num_tasks=num_tasks, + max_steps=max_steps, + max_actions=max_actions, + model=model, + debug=debug, + log_level=log_level, + output_dir=output_dir, + cache_dir=cache_dir, + run_id=run_id, + ) + + +def _load_config_file(path: str) -> dict: + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def _run_config_from_session(session_config: SessionConfig) -> RunConfig: + return RunConfig( + benchmark=session_config.benchmark, + agent=session_config.agent, + subset=session_config.subset, + task_ids=[session_config.task_id], + output_dir=session_config.output_dir, + cache_dir=session_config.cache_dir, + run_id=session_config.run_id, + model=session_config.model, + benchmark_kwargs=session_config.benchmark_kwargs, + agent_kwargs=session_config.agent_kwargs, + ) + + +@click.command("preview") +@add_run_options(include_overwrite=False, include_max_workers=False) +@click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text") +def preview_cmd( + benchmark: str, + agent: str, + agent_json: str | None, + agent_arg: tuple[str, ...], + set_values: tuple[str, ...], + subset: str | None, + tasks: tuple[str, ...], + num_tasks: int | None, + max_steps: int | None, + max_actions: int | None, + model: str | None, + debug: bool, + log_level: str | None, + output_dir: str, + cache_dir: str | None, + run_id: str | None, + output_format: str, +) -> None: + """Show the planned execution for a run.""" + run_query( + run_func=preview, + render_func=render_run_plan, + output_format=output_format, + benchmark=benchmark, + agent=agent, + agent_json=agent_json, + agent_arg=agent_arg, + set_values=set_values, + subset=subset, + tasks=tasks, + num_tasks=num_tasks, + max_steps=max_steps, + max_actions=max_actions, + model=model, + debug=debug, + log_level=log_level, + output_dir=output_dir, + cache_dir=cache_dir, + run_id=run_id, + ) + + +@click.command("results") +@add_run_options(include_overwrite=False, include_max_workers=False) +@click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text") +def results_cmd( + benchmark: str, + agent: str, + agent_json: str | None, + agent_arg: tuple[str, ...], + set_values: tuple[str, ...], + subset: str | None, + tasks: tuple[str, ...], + num_tasks: int | None, + max_steps: int | None, + max_actions: int | None, + model: str | None, + debug: bool, + log_level: str | None, + output_dir: str, + cache_dir: str | None, + run_id: str | None, + output_format: str, +) -> None: + """Load the saved run results.""" + run_query( + run_func=results, + render_func=render_run_results, + output_format=output_format, + benchmark=benchmark, + agent=agent, + agent_json=agent_json, + agent_arg=agent_arg, + set_values=set_values, + subset=subset, + tasks=tasks, + num_tasks=num_tasks, + max_steps=max_steps, + max_actions=max_actions, + model=model, + debug=debug, + log_level=log_level, + output_dir=output_dir, + cache_dir=cache_dir, + run_id=run_id, + ) + + +__all__ = ["status_cmd", "preview_cmd", "results_cmd"] diff --git a/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/commands/serve.py b/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/commands/serve.py new file mode 100644 index 00000000..2399893f --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/commands/serve.py @@ -0,0 +1,61 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import logging + +import rich_click as click + +from ..options import apply_debug_mode + +logger = logging.getLogger(__name__) + + +@click.command("serve") +@click.option("--host", default="0.0.0.0", help="Host to bind to") +@click.option("--port", type=int, default=8080, help="Port to listen on") +@click.option("--cls", required=True, help="Class to serve (module.path:ClassName)") +@click.option("--kwargs", "kwargs_json", default=None, help="JSON constructor kwargs") +@click.option("--kwargs-b64", "kwargs_b64", default=None, help="Base64-encoded cloudpickle kwargs") +@click.option("--debug", is_flag=True, hidden=True) +def serve_cmd(host: str, port: int, cls: str, kwargs_json: str | None, kwargs_b64: str | None, debug: bool) -> None: + """Serve a class instance over HTTP.""" + apply_debug_mode(debug) + + import importlib + import json + import os + + from ....core.context import init_context_from_env + + try: + init_context_from_env() + except RuntimeError as exc: + logger.warning("Context init failed: %s", exc) + ctx_vars = {k: v for k, v in os.environ.items() if k.startswith("EXGENTIC_CTX")} + logger.debug("Context env vars: %s", ctx_vars) + + from ....adapters.runners.service import serve + + # Import the target module FIRST so that package __init__.py files + # (which may set environment variables like TAU2_DATA_DIR) run before + # cloudpickle deserialization triggers transitive library imports. + module_path, attr_name = cls.rsplit(":", 1) + mod = importlib.import_module(module_path) + klass = getattr(mod, attr_name) + + # Deserialize kwargs: JSON (preferred) or cloudpickle fallback. + if kwargs_b64 is not None: + import base64 + + import cloudpickle as cp + + kw = cp.loads(base64.b64decode(kwargs_b64)) + elif kwargs_json is not None: + kw = json.loads(kwargs_json) + else: + kw = {} + + obj = klass(**kw) + serve(obj, host=host, port=port) diff --git a/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/commands/setup.py b/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/commands/setup.py new file mode 100644 index 00000000..44ce50fd --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/commands/setup.py @@ -0,0 +1,166 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import rich_click as click + +from ..options import apply_debug_mode + + +def _get_registry_entry(slug: str, kind: str): + """Look up a RegistryEntry for the given slug and kind ('benchmark' or 'agent').""" + from ...registry import AGENTS, BENCHMARKS + + registry = BENCHMARKS if kind == "benchmark" else AGENTS + entry = registry.get(slug) + if entry is None: + raise click.ClickException(f"Unknown {kind} slug '{slug}'") + return entry + + +@click.command("install") +@click.option("--benchmark", "benchmark", default=None, help="Benchmark slug name to install.") +@click.option("--agent", "agent", default=None, help="Agent slug name to install.") +@click.option("--force", is_flag=True, help="Force reinstall even if already installed.") +@click.option("--docker", is_flag=True, help="Build a Docker image for the environment.") +@click.option("--local", is_flag=True, help="Install into the current Python (no isolation).") +def install_cmd(benchmark: str | None, agent: str | None, force: bool, docker: bool, local: bool) -> None: + """Install a benchmark or agent environment. + + By default, creates an isolated Python venv with all dependencies. + Use --docker to build a Docker image, or --local to install into + the current Python (no isolation). + """ + from ....environment import EnvType + from ....environment.instance import get_manager + + if benchmark is not None and agent is not None: + raise click.UsageError("Specify either --benchmark or --agent, not both.") + if benchmark is None and agent is None: + raise click.UsageError("Specify either --benchmark or --agent.") + + if docker and local: + raise click.UsageError("Specify either --docker or --local, not both.") + + if docker: + env_type = EnvType.DOCKER + elif local: + env_type = EnvType.LOCAL + else: + env_type = EnvType.VENV + + mgr = get_manager() + + try: + if benchmark is not None: + entry = _get_registry_entry(benchmark, "benchmark") + name = f"benchmarks/{benchmark}" + else: + entry = _get_registry_entry(agent, "agent") + name = f"agents/{agent}" + + kwargs: dict = {"env_type": env_type, "module_path": entry.module, "force": force} + if env_type in (EnvType.VENV, EnvType.DOCKER): + from ....environment.helpers import get_exgentic_install_target + + project_root, packages = get_exgentic_install_target() + if project_root is not None: + kwargs["project_root"] = project_root + if packages: + kwargs["packages"] = packages + mgr.install(name, **kwargs) + except Exception as exc: + raise click.ClickException(str(exc)) from exc + + +@click.command("uninstall") +@click.option("--benchmark", "benchmark", default=None, help="Benchmark slug name to uninstall.") +@click.option("--agent", "agent", default=None, help="Agent slug name to uninstall.") +@click.option("--docker", is_flag=True, help="Uninstall Docker environment only.") +@click.option("--local", is_flag=True, help="Uninstall local environment only.") +def uninstall_cmd(benchmark: str | None, agent: str | None, docker: bool, local: bool) -> None: + """Uninstall a benchmark or agent environment. + + Without flags, removes all environment types for the given name. + """ + from ....environment import EnvType + from ....environment.instance import get_manager + + if benchmark is not None and agent is not None: + raise click.UsageError("Specify either --benchmark or --agent, not both.") + if benchmark is None and agent is None: + raise click.UsageError("Specify either --benchmark or --agent.") + + if docker and local: + raise click.UsageError("Specify either --docker or --local, not both.") + + if docker: + env_type = EnvType.DOCKER + elif local: + env_type = EnvType.LOCAL + else: + env_type = None + + mgr = get_manager() + + try: + if benchmark is not None: + name = f"benchmarks/{benchmark}" + else: + name = f"agents/{agent}" + + if env_type is not None: + mgr.uninstall(name, env_type=env_type) + else: + mgr.uninstall(name) + except Exception as exc: + raise click.ClickException(str(exc)) from exc + + +@click.command("setup") +@click.option( + "--debug", + is_flag=True, + help="Enable debug mode (sets settings.debug=true and log level to DEBUG)", +) +@click.option("--benchmark", "benchmark", default=None, help="Benchmark slug name to set up.") +@click.option("--agent", "agent", default=None, help="Agent slug name to set up.") +@click.option( + "--force", + is_flag=True, + help="Force reinstall even if already installed.", +) +def setup_cmd(debug: bool, benchmark: str | None, agent: str | None, force: bool) -> None: + """[Deprecated] Use 'exgentic install' instead.""" + apply_debug_mode(debug) + click.echo( + "WARNING: 'exgentic setup' is deprecated. Use 'exgentic install' instead.", + err=True, + ) + + # Delegate to install logic + from ....environment import EnvType + from ....environment.instance import get_manager + + if benchmark is not None and agent is not None: + raise click.UsageError("Specify either --benchmark or --agent, not both.") + if benchmark is None and agent is None: + raise click.UsageError("Specify either --benchmark or --agent.") + + mgr = get_manager() + + try: + if benchmark is not None: + entry = _get_registry_entry(benchmark, "benchmark") + name = f"benchmarks/{benchmark}" + else: + entry = _get_registry_entry(agent, "agent") + name = f"agents/{agent}" + + mgr.install(name, env_type=EnvType.LOCAL, module_path=entry.module, force=force) + except Exception as exc: + raise click.ClickException(str(exc)) from exc + + +__all__ = ["install_cmd", "setup_cmd", "uninstall_cmd"] diff --git a/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/main.py b/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/main.py new file mode 100644 index 00000000..6e827315 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/main.py @@ -0,0 +1,126 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import rich_click as click + +from ... import __version__ +from .commands.analyze import analyse_cmd +from .commands.batch import batch_cmd +from .commands.compare import compare_cmd +from .commands.dashboard import dashboard_cmd +from .commands.evaluate import evaluate_cmd +from .commands.listing import list_cmd +from .commands.run_info import preview_cmd, results_cmd, status_cmd +from .commands.serve import serve_cmd +from .commands.setup import install_cmd, setup_cmd, uninstall_cmd +from .options import apply_debug_mode +from .render import print_banner, should_print_banner + +click.rich_click.text_markup = False +click.rich_click.show_arguments = True +click.rich_click.options_table_column_types = [ + "required", + "opt_short", + "opt_long", + "metavar", + "help", +] +click.rich_click.show_envvar = True +click.rich_click.COMMAND_GROUPS = { + "exgentic": [ + { + "name": "Run", + "commands": [ + "evaluate", + "batch", + "status", + "preview", + "results", + ], + }, + { + "name": "Analyze", + "commands": ["compare", "analyse"], + }, + { + "name": "Discover", + "commands": ["list", "install", "uninstall", "setup"], + }, + { + "name": "Explore", + "commands": ["dashboard"], + }, + { + "name": "Infrastructure", + "commands": ["serve"], + }, + ] +} + + +def _version_callback(ctx: click.Context, param: click.Parameter, value: bool) -> None: + if not value or ctx.resilient_parsing: + return + click.echo(f"exgentic {__version__}") + ctx.exit() + + +@click.group() +@click.option( + "--version", + "-V", + is_flag=True, + callback=_version_callback, + expose_value=False, + is_eager=True, + help="Show version and exit.", +) +@click.option( + "--debug", + is_flag=True, + help="Enable debug mode (sets settings.debug=true and log level to DEBUG)", +) +def cli(debug: bool) -> None: + """Exgentic CLI.""" + apply_debug_mode(debug) + + +cli.add_command(evaluate_cmd) +cli.add_command(batch_cmd) +cli.add_command(analyse_cmd) +cli.add_command(status_cmd) +cli.add_command(preview_cmd) +cli.add_command(results_cmd) +cli.add_command(compare_cmd) +cli.add_command(list_cmd) +cli.add_command(dashboard_cmd) +cli.add_command(setup_cmd) +cli.add_command(install_cmd) +cli.add_command(uninstall_cmd) +cli.add_command(serve_cmd) + + +def main() -> None: + if should_print_banner(): + print_banner() + cli() + + +__all__ = [ + "batch_cmd", + "cli", + "compare_cmd", + "dashboard_cmd", + "evaluate_cmd", + "install_cmd", + "list_cmd", + "main", + "preview_cmd", + "results_cmd", + "serve_cmd", + "setup_cmd", + "status_cmd", + "uninstall_cmd", +] diff --git a/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/options.py b/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/options.py new file mode 100644 index 00000000..aa64f265 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/options.py @@ -0,0 +1,474 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import json +import traceback +from typing import Any + +import rich_click as click + +from ...core.types import ModelSettings, RunConfig +from ...utils.settings import ExgenticSettings, get_settings + +DEFAULT_OUTPUT_DIR = "./outputs" + + +def _api(): + from ..lib import api + + return api + + +def apply_debug_mode(debug: bool) -> None: + if not debug: + return + settings = get_settings() + settings.debug = True + settings.log_level = "DEBUG" + + +def _should_show_traceback() -> bool: + return bool(get_settings().debug) + + +def _format_exception_for_cli(exc: Exception) -> str: + parts = [str(exc)] + remote_tb = getattr(exc, "__remote_traceback__", None) + if remote_tb: + parts.append(f"Remote traceback:\n{str(remote_tb).rstrip()}") + local_tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)) + if local_tb.strip(): + parts.append(f"Local traceback:\n{local_tb.rstrip()}") + stdout = getattr(exc, "stdout", None) + if stdout: + parts.append(f"stdout:\n{str(stdout).rstrip()}") + stderr = getattr(exc, "stderr", None) + if stderr: + parts.append(f"stderr:\n{str(stderr).rstrip()}") + return "\n\n".join(parts) + + +def _load_json_arg(raw: str | None) -> dict[str, Any]: + if not raw: + return {} + if raw.startswith("@"): + path = raw[1:] + with open(path, encoding="utf-8") as f: + return json.load(f) + return json.loads(raw) + + +def _parse_kv_list(values: tuple[str, ...]) -> dict[str, Any]: + payload: dict[str, Any] = {} + for item in values: + if "=" not in item: + raise click.ClickException(f"Expected key=value, got '{item}'") + key, raw = item.split("=", 1) + try: + value = json.loads(raw) + except Exception: + value = raw + payload[key] = value + return payload + + +def _set_nested(target: dict[str, Any], path: list[str], value: Any) -> None: + if not path or any(not part for part in path): + raise click.ClickException("Invalid --set key (empty path).") + cur = target + for part in path[:-1]: + if part in cur and not isinstance(cur[part], dict): + raise click.ClickException(f"Conflicting --set path at '{part}': not a mapping.") + cur = cur.setdefault(part, {}) + leaf = path[-1] + if leaf in cur and cur[leaf] != value: + raise click.ClickException(f"Conflicting --set value for '{'.'.join(path)}'.") + cur[leaf] = value + + +def _parse_set_list(values: tuple[str, ...]) -> list[tuple[str, list[str], Any]]: + items: list[tuple[str, list[str], Any]] = [] + for item in values: + if "=" not in item: + raise click.ClickException(f"Expected key=value, got '{item}'") + key, raw = item.split("=", 1) + try: + value = json.loads(raw) + except Exception: + value = raw + if key.startswith("benchmark."): + path = key.split(".")[1:] + items.append(("benchmark", path, value)) + elif key.startswith("agent.model."): + path = ["model_settings", *key.split(".")[2:]] + items.append(("agent", path, value)) + elif key.startswith("agent."): + path = key.split(".")[1:] + items.append(("agent", path, value)) + elif key.startswith("settings."): + path = key.split(".")[1:] + items.append(("settings", path, value)) + else: + raise click.ClickException( + "Invalid --set key. Use benchmark.=..., agent.=..., " "or settings.=..." + ) + return items + + +def _validate_set_keys_for_benchmark(benchmark: str, items: list[tuple[str, list[str], Any]]) -> None: + try: + info = _api().get_benchmark_info(benchmark) + except ImportError: + # Benchmark has uninstalled deps (e.g. running with runner=docker + # before setup on the host). Skip --set validation; the container + # will catch real errors at runtime. + return + except Exception as exc: + raise click.ClickException(str(exc)) from exc + forbidden = {"num_tasks", "subset"} + subset_arg = info.get("subset_arg") + if subset_arg: + forbidden.add(subset_arg) + allowed = set(info.get("kwargs") or []) + allow_any = "**kwargs" in allowed + for group, path, _ in items: + if group != "benchmark" or not path: + continue + if path[0] in forbidden: + raise click.ClickException(f"Use --subset/--num-tasks instead of --set benchmark.{path[0]}.") + if not allow_any and path[0] not in allowed: + raise click.ClickException( + f"Unknown benchmark override '{path[0]}'. " f"Available: {', '.join(sorted(allowed))}" + ) + + +def _validate_set_keys_for_agent(agent: str, items: list[tuple[str, list[str], Any]]) -> None: + try: + info = _api().get_agent_info(agent) + except ImportError: + return + except Exception as exc: + raise click.ClickException(str(exc)) from exc + allowed = set(info.get("kwargs") or []) + allow_any = "**kwargs" in allowed + model_fields = set(ModelSettings.model_fields.keys()) + for group, path, _ in items: + if group != "agent" or not path: + continue + if path[0] == "model_settings": + if len(path) < 2 or path[1] not in model_fields: + raise click.ClickException( + f"Unknown agent model override '{'.'.join(path)}'. " f"Available: {', '.join(sorted(model_fields))}" + ) + continue + if not allow_any and path[0] not in allowed: + raise click.ClickException( + f"Unknown agent override '{path[0]}'. " f"Available: {', '.join(sorted(allowed))}" + ) + + +def _validate_set_keys_for_settings( + items: list[tuple[str, list[str], Any]], +) -> None: + allowed = set(ExgenticSettings.model_fields.keys()) + for group, path, _ in items: + if group != "settings" or not path: + continue + if len(path) != 1 or path[0] not in allowed: + raise click.ClickException( + f"Unknown settings override '{'.'.join(path)}'. " f"Available: {', '.join(sorted(allowed))}" + ) + + +def _apply_settings_overrides(items: list[tuple[str, list[str], Any]]) -> None: + settings = get_settings() + for group, path, value in items: + if group != "settings" or not path: + continue + setattr(settings, path[0], value) + + +def _apply_options(target, specs): + for args, kwargs in reversed(specs): + target = click.option(*args, **kwargs)(target) + return target + + +def _run_option_specs( + *, + required: bool, + include_overwrite: bool, + include_max_workers: bool, +): + specs = [ + (("--benchmark",), {"required": required, "help": "Benchmark slug_name"}), + (("--agent",), {"required": required, "help": "Agent slug_name"}), + (("--agent-json",), {"help": "Agent kwargs JSON (or @file)"}), + (("--agent-arg",), {"multiple": True, "help": "Agent kwarg key=value"}), + ( + ("--set", "set_values"), + { + "multiple": True, + "help": "Set benchmark.*, agent.*, or settings.* values", + }, + ), + (("--subset",), {"help": "Benchmark subset name"}), + (("--task", "tasks"), {"multiple": True, "help": "Task to run (repeatable)"}), + (("--num-tasks",), {"type": int, "help": "Number of tasks to run"}), + (("--max-steps",), {"type": int, "help": "Max steps per session"}), + (("--max-actions",), {"type": int, "help": "Max actions per session"}), + (("--model",), {"help": "Agent model (must be supported by the agent)"}), + ( + ("--debug",), + { + "is_flag": True, + "help": "Enable debug mode (sets settings.debug=true and log level to DEBUG)", + }, + ), + ( + ("--log-level",), + { + "type": click.Choice( + ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + case_sensitive=False, + ), + "help": "Override EXGENTIC_LOG_LEVEL for this run", + }, + ), + (("--output-dir",), {"default": DEFAULT_OUTPUT_DIR, "show_default": True}), + ( + ("--cache-dir",), + {"help": "Base cache directory (overrides EXGENTIC_CACHE_DIR)"}, + ), + (("--run-id",), {"help": "Run id (overrides context env var)"}), + ] + if include_overwrite: + specs.append( + ( + ("--overwrite",), + { + "is_flag": True, + "help": "Overwrite existing sessions instead of skipping them", + }, + ) + ) + if include_max_workers: + specs.append( + ( + ("--max-workers",), + { + "type": int, + "help": "Parallel workers (>=2 runs sessions in parallel)", + }, + ) + ) + return specs + + +def add_run_options( + func=None, + *, + required: bool = True, + include_overwrite: bool = True, + include_max_workers: bool = True, +): + def decorator(target): + specs = _run_option_specs( + required=required, + include_overwrite=include_overwrite, + include_max_workers=include_max_workers, + ) + return _apply_options(target, specs) + + if func is None: + return decorator + return decorator(func) + + +def has_run_options( + *, + benchmark: str | None, + agent: str | None, + agent_json: str | None, + agent_arg: tuple[str, ...], + set_values: tuple[str, ...], + subset: str | None, + tasks: tuple[str, ...], + num_tasks: int | None, + model: str | None, + debug: bool, + overwrite: bool, + log_level: str | None, + output_dir: str | None, + cache_dir: str | None, + run_id: str | None, + max_workers: int | None, + max_steps: int | None, + max_actions: int | None, +) -> bool: + if benchmark or agent or agent_json or subset or num_tasks or model or log_level: + return True + if agent_arg or set_values or tasks: + return True + if overwrite or run_id or max_workers is not None: + return True + if max_steps is not None or max_actions is not None: + return True + if cache_dir: + return True + if output_dir and output_dir != DEFAULT_OUTPUT_DIR: + return True + return False + + +def build_run_config( + *, + benchmark: str, + agent: str, + agent_json: str | None, + agent_arg: tuple[str, ...], + set_values: tuple[str, ...], + subset: str | None, + tasks: tuple[str, ...], + num_tasks: int | None, + model: str | None, + output_dir: str, + cache_dir: str | None, + run_id: str | None, + max_workers: int | None, + max_steps: int | None, + max_actions: int | None, + overwrite: bool, +) -> RunConfig: + bench_kwargs: dict[str, Any] = {} + agent_kwargs = _load_json_arg(agent_json) + agent_kwargs.update(_parse_kv_list(agent_arg)) + set_items = _parse_set_list(set_values) + _validate_set_keys_for_settings(set_items) + _apply_settings_overrides(set_items) + _validate_set_keys_for_benchmark(benchmark, set_items) + _validate_set_keys_for_agent(agent, set_items) + for group, path, value in set_items: + if group == "benchmark": + _set_nested(bench_kwargs, path, value) + elif group == "agent": + _set_nested(agent_kwargs, path, value) + return RunConfig( + benchmark=benchmark, + agent=agent, + subset=subset, + task_ids=list(tasks) if tasks else None, + num_tasks=num_tasks, + output_dir=output_dir, + cache_dir=cache_dir, + run_id=run_id, + model=model, + max_workers=max_workers, + max_steps=(RunConfig.model_fields["max_steps"].default if max_steps is None else max_steps), + max_actions=(RunConfig.model_fields["max_actions"].default if max_actions is None else max_actions), + overwrite_sessions=overwrite, + benchmark_kwargs=bench_kwargs, + agent_kwargs=agent_kwargs, + ) + + +def run_with( + run_func, + benchmark: str, + agent: str, + agent_json: str | None, + agent_arg: tuple[str, ...], + set_values: tuple[str, ...], + subset: str | None, + tasks: tuple[str, ...], + num_tasks: int | None, + model: str | None, + debug: bool, + overwrite: bool, + log_level: str | None, + output_dir: str, + cache_dir: str | None, + run_id: str | None, + max_workers: int | None, + max_steps: int | None, + max_actions: int | None, +) -> Any: + """Run a benchmark with an agent by slug name.""" + if log_level: + settings = get_settings() + level = log_level.upper() + settings.log_level = level + settings.debug = level == "DEBUG" + apply_debug_mode(debug) + try: + config = build_run_config( + benchmark=benchmark, + agent=agent, + agent_json=agent_json, + agent_arg=agent_arg, + set_values=set_values, + subset=subset, + tasks=tasks, + num_tasks=num_tasks, + model=model, + output_dir=output_dir, + cache_dir=cache_dir, + run_id=run_id, + max_workers=max_workers, + max_steps=max_steps, + max_actions=max_actions, + overwrite=overwrite, + ) + return run_func(config) + except Exception as exc: + if _should_show_traceback(): + raise click.ClickException(_format_exception_for_cli(exc)) from exc + raise click.ClickException(str(exc)) from exc + + +def run_query( + *, + run_func, + render_func, + output_format: str, + benchmark: str, + agent: str, + agent_json: str | None, + agent_arg: tuple[str, ...], + set_values: tuple[str, ...], + subset: str | None, + tasks: tuple[str, ...], + num_tasks: int | None, + model: str | None, + debug: bool, + log_level: str | None, + output_dir: str, + cache_dir: str | None, + run_id: str | None, + max_steps: int | None, + max_actions: int | None, +) -> None: + result = run_with( + run_func, + benchmark=benchmark, + agent=agent, + agent_json=agent_json, + agent_arg=agent_arg, + set_values=set_values, + subset=subset, + tasks=tasks, + num_tasks=num_tasks, + model=model, + debug=debug, + overwrite=False, + log_level=log_level, + output_dir=output_dir, + cache_dir=cache_dir, + run_id=run_id, + max_workers=None, + max_steps=max_steps, + max_actions=max_actions, + ) + render_func(result, output_format) diff --git a/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/render.py b/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/render.py new file mode 100644 index 00000000..96bad505 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/render.py @@ -0,0 +1,263 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import json +import sys +from typing import Any + +from rich import box +from rich.align import Align +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +CONSOLE = Console() + + +def print_json(data: Any) -> None: + CONSOLE.print_json(json.dumps(data, ensure_ascii=False, indent=2)) + + +def render_list(items: list[str], output_format: str, *, title: str = "Items") -> None: + if output_format == "json": + print_json(items) + return + table = Table(title=title, box=box.SIMPLE, show_header=False) + table.add_column("Item") + if not items: + table.add_row("[dim]none[/dim]") + else: + for item in items: + table.add_row(str(item)) + CONSOLE.print(table) + + +def render_named_list( + items: list[dict[str, Any]], + output_format: str, + *, + fields: tuple[str, str] = ("slug_name", "display_name"), + title: str = "Items", +) -> None: + if output_format == "json": + print_json(items) + return + + # Check if items have installation info + has_install_info = items and "installed" in items[0] + + table = Table(title=title, box=box.SIMPLE, show_header=True, header_style="bold magenta") + table.add_column("Slug") + table.add_column("Name") + if has_install_info: + table.add_column("Installed", justify="center") + table.add_column("Installed At") + + if not items: + if has_install_info: + table.add_row("[dim]none[/dim]", "[dim]none[/dim]", "[dim]-[/dim]", "[dim]-[/dim]") + else: + table.add_row("[dim]none[/dim]", "[dim]none[/dim]") + else: + for item in items: + slug = str(item[fields[0]]) + name = str(item[fields[1]]) + if has_install_info: + installed = item.get("installed", False) + installed_at = item.get("installed_at", "") + status = "[green]✓[/green]" if installed else "[dim]-[/dim]" + # Format the timestamp to be more readable + if installed_at: + try: + from datetime import datetime + + dt = datetime.fromisoformat(installed_at.replace("Z", "+00:00")) + installed_at = dt.strftime("%Y-%m-%d %H:%M UTC") + except Exception: + pass + table.add_row(slug, name, status, installed_at if installed_at else "[dim]-[/dim]") + else: + table.add_row(slug, name) + CONSOLE.print(table) + + +def render_model(obj: Any, output_format: str) -> None: + if output_format == "json": + print_json(obj.model_dump()) + return + CONSOLE.print(str(obj)) + + +def render_run_status(status: Any, output_format: str) -> None: + if output_format == "json": + render_model(status, output_format) + return + meta = Table.grid(padding=(0, 2)) + meta.add_column(style="bold cyan") + meta.add_column() + meta.add_row("Run", str(status.run_id)) + meta.add_row("Results", "yes" if status.results_exists else "no") + meta.add_row("Benchmark Results", "yes" if status.benchmark_results_exists else "no") + CONSOLE.print(Panel(meta, title="Run Status", border_style="cyan")) + + counts = Table( + title="Sessions", + box=box.SIMPLE, + show_header=True, + header_style="bold magenta", + ) + counts.add_column("Total", justify="right") + counts.add_column("Completed", justify="right") + counts.add_column("Running", justify="right") + counts.add_column("Incomplete", justify="right") + counts.add_column("Missing", justify="right") + counts.add_row( + str(status.total_tasks), + str(status.completed_sessions), + str(status.running_sessions), + str(status.incomplete_sessions), + str(status.missing_sessions), + ) + CONSOLE.print(counts) + + +def render_batch_status(rows: list[dict[str, str]]) -> None: + total_configs = len(rows) + done_configs = sum(1 for row in rows if row.get("ready") == row.get("finished")) + total_sessions = 0 + total_ready = 0 + for row in rows: + ready = row.get("ready", "-") + if isinstance(ready, str) and "/" in ready: + try: + done_str, total_str = ready.split("/", 1) + total_sessions += int(total_str) + total_ready += int(done_str) + except Exception: + continue + + summary = Table.grid(padding=(0, 2)) + summary.add_column(style="bold cyan") + summary.add_column() + summary.add_row("Configs", f"{total_configs} total") + summary.add_row("Fully Done", f"{done_configs} total") + summary.add_row("Sessions", f"{total_ready}/{total_sessions}") + CONSOLE.print(Panel(summary, title="Batch Summary", border_style="cyan")) + + table = Table( + title="Batch Status", + box=box.SIMPLE, + show_header=True, + header_style="bold magenta", + ) + table.add_column("#", justify="right", no_wrap=True) + table.add_column("Config", overflow="crop", max_width=30) + table.add_column("Run", no_wrap=True, min_width=8) + table.add_column("Benchmark", no_wrap=True) + table.add_column("Agent", no_wrap=True) + table.add_column("Subset", no_wrap=True) + table.add_column("Models", overflow="crop", max_width=24) + table.add_column("Ready", justify="right") + table.add_column("Aggregated", justify="right") + table.add_column("Finished", justify="right") + table.add_column("Errors", justify="right") + table.add_column("Score", justify="right") + table.add_column("Cost", justify="right") + + for row in rows: + table.add_row( + row["#"], + row["config"], + row["run_id"], + row["benchmark"], + row["agent"], + row.get("subset", "-"), + row["models"], + row["ready"], + row["aggregated"], + row["finished"], + row["errors"], + row["score"], + row["cost"], + ) + + CONSOLE.print(table) + + +def render_run_plan(plan: Any, output_format: str) -> None: + if output_format == "json": + render_model(plan, output_format) + return + meta = Table.grid(padding=(0, 2)) + meta.add_column(style="bold cyan") + meta.add_column() + meta.add_row("Run", str(plan.run_config.run_id)) + meta.add_row("Overwrite", "yes" if plan.overwrite_sessions else "no") + CONSOLE.print(Panel(meta, title="Run Plan", border_style="cyan")) + + counts = Table( + title="Sessions", + box=box.SIMPLE, + show_header=True, + header_style="bold magenta", + ) + counts.add_column("Total", justify="right") + counts.add_column("To Run", justify="right") + counts.add_column("Reuse", justify="right") + counts.add_column("Running", justify="right") + counts.add_column("Missing", justify="right") + counts.add_column("Incomplete", justify="right") + total_sessions = len(plan.to_run) + len(plan.reuse) + len(plan.running) + len(plan.missing) + len(plan.incomplete) + counts.add_row( + str(total_sessions), + str(len(plan.to_run)), + str(len(plan.reuse)), + str(len(plan.running)), + str(len(plan.missing)), + str(len(plan.incomplete)), + ) + CONSOLE.print(counts) + + +def render_run_results(results: Any, output_format: str) -> None: + if output_format == "json": + render_model(results, output_format) + return + meta = Table.grid(padding=(0, 2)) + meta.add_column(style="bold cyan") + meta.add_column() + meta.add_row("Benchmark", str(results.benchmark_name)) + meta.add_row("Agent", str(results.agent_name)) + CONSOLE.print(Panel(meta, title="Run Results", border_style="cyan")) + + summary = Table( + title="Summary", + box=box.SIMPLE, + show_header=True, + header_style="bold magenta", + ) + summary.add_column("Sessions", justify="right") + summary.add_column("Successes", justify="right") + summary.add_column("Final Score", justify="right") + summary.add_column("Avg Score", justify="right") + summary.add_row( + str(results.total_sessions), + str(results.successful_sessions), + str(results.benchmark_score), + str(results.average_score), + ) + CONSOLE.print(summary) + + +def should_print_banner() -> bool: + args = sys.argv[1:] + if not args: + return True + return any(arg in ("-h", "--help") for arg in args) + + +def print_banner() -> None: + title = Align.center("[bold magenta]EXGENTIC[/bold magenta]\n" "[dim]General Agent Evaluation[/dim]") + CONSOLE.print(Panel(title, border_style="magenta", padding=(1, 8))) diff --git a/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/run.py b/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/run.py new file mode 100644 index 00000000..0b9c4e27 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/interfaces/cli/run.py @@ -0,0 +1,9 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from .main import main + +if __name__ == "__main__": + main() diff --git a/labs/AgentStream/exgentic/src/exgentic/interfaces/dashboard/__init__.py b/labs/AgentStream/exgentic/src/exgentic/interfaces/dashboard/__init__.py new file mode 100644 index 00000000..367fec41 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/interfaces/dashboard/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. diff --git a/labs/AgentStream/exgentic/src/exgentic/interfaces/dashboard/app.py b/labs/AgentStream/exgentic/src/exgentic/interfaces/dashboard/app.py new file mode 100644 index 00000000..88890f8d --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/interfaces/dashboard/app.py @@ -0,0 +1,243 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from pathlib import Path + +from nicegui import ui + +from .views import ( + RunState, + build_history_tab, + build_leaderboard_tab, + build_run_tab, + refresh_ui, +) + +ASSETS_DIR = Path(__file__).resolve().parents[3] / "assets" + + +def create_ui() -> None: + state = RunState() + + ui.page_title("Exgentic Dashboard") + ui.add_head_html( + """ + + + + + """ + ) + dark = ui.dark_mode() + dark.disable() + + with ui.row().classes("w-full items-center justify-between"): + logo_path = ASSETS_DIR.parent.parent / "misc" / "assets" / "exgentic_banner_black_no_background.png" + if logo_path.is_file(): + ui.image(logo_path).classes("app-logo").props("fit=contain") + with ui.tabs() as main_tabs: + run_tab = ui.tab("Run") + leaderboard_tab = ui.tab("Leaderboard") + history_tab = ui.tab("History") + + with ui.tab_panels(main_tabs, value=run_tab).classes("w-full"): + with ui.tab_panel(run_tab): + run_views = build_run_tab(state) + with ui.tab_panel(leaderboard_tab): + build_leaderboard_tab(state) + with ui.tab_panel(history_tab): + build_history_tab(state) + + ui.timer(0.2, lambda: refresh_ui(state, run_views)) + + +def main() -> None: + ui.run(root=create_ui, reload=False, dark=False) + + +if __name__ == "__main__": + main() diff --git a/labs/AgentStream/exgentic/src/exgentic/interfaces/dashboard/views/__init__.py b/labs/AgentStream/exgentic/src/exgentic/interfaces/dashboard/views/__init__.py new file mode 100644 index 00000000..7734a5cd --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/interfaces/dashboard/views/__init__.py @@ -0,0 +1,19 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from .runtime import ( + build_history_tab, + build_leaderboard_tab, + build_run_tab, + refresh_ui, +) +from .state import RunState, RunViews + +__all__ = [ + "RunState", + "RunViews", + "build_run_tab", + "build_leaderboard_tab", + "build_history_tab", + "refresh_ui", +] diff --git a/labs/AgentStream/exgentic/src/exgentic/interfaces/dashboard/views/data.py b/labs/AgentStream/exgentic/src/exgentic/interfaces/dashboard/views/data.py new file mode 100644 index 00000000..8494274c --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/interfaces/dashboard/views/data.py @@ -0,0 +1,529 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any, Optional + +from ....utils.paths import RunPaths +from ....utils.settings import get_settings +from ...lib.api import list_agents, list_benchmarks +from .state import RunContext +from .status import _status_from_outcome + + +def get_display_mappings() -> tuple[dict[str, str], dict[str, str]]: + bench_label_to_key: dict[str, str] = {} + for item in list_benchmarks(): + bench_label_to_key[str(item["display_name"])] = item["slug_name"] + + agent_label_to_key: dict[str, str] = {} + for item in list_agents(): + agent_label_to_key[str(item["display_name"])] = item["slug_name"] + + return bench_label_to_key, agent_label_to_key + + +def _build_overview_secondary_metrics( + sessions: dict, + results: Optional[dict], +) -> list[tuple[str, Any]]: + total_sessions = None + successful_sessions = None + percent_successful = None + percent_finished = None + percent_finished_unsuccessful = None + percent_unfinished = None + percent_error = None + avg_score = None + avg_steps = None + avg_agent_cost = None + avg_benchmark_cost = None + total_run_cost = None + if isinstance(results, dict): + total_sessions = results.get("total_sessions") + successful_sessions = results.get("successful_sessions") + percent_successful = results.get("percent_successful") + percent_finished = results.get("percent_finished") + percent_finished_unsuccessful = results.get("percent_finished_unsuccessful") + percent_unfinished = results.get("percent_unfinished") + percent_error = results.get("percent_error") + avg_score = results.get("average_score") + avg_steps = results.get("average_steps") + avg_agent_cost = results.get("average_agent_cost") + avg_benchmark_cost = results.get("average_benchmark_cost") + total_run_cost = results.get("total_run_cost") + + if total_sessions is None: + total_sessions = len(sessions) + if successful_sessions is None: + successful_sessions = sum(1 for s in sessions.values() if s.get("status") == "success") + + if avg_steps is None: + steps = [s.get("steps") for s in sessions.values() if s.get("steps") is not None] + if steps: + avg_steps = sum(float(x) for x in steps) / len(steps) + if avg_score is None: + scores = [s.get("score") for s in sessions.values() if s.get("score") is not None] + if scores: + avg_score = sum(float(x) for x in scores) / len(scores) + if avg_agent_cost is None: + costs = [s.get("agent_cost") for s in sessions.values() if s.get("agent_cost") is not None] + if costs: + avg_agent_cost = sum(float(x) for x in costs) / len(costs) + if avg_benchmark_cost is None: + costs = [s.get("benchmark_cost") for s in sessions.values() if s.get("benchmark_cost") is not None] + if costs: + avg_benchmark_cost = sum(float(x) for x in costs) / len(costs) + if total_run_cost is None: + total_run_cost = None + had_cost = False + for s in sessions.values(): + agent_cost = s.get("agent_cost") + benchmark_cost = s.get("benchmark_cost") + if agent_cost is not None: + total_run_cost = (total_run_cost or 0.0) + float(agent_cost) + had_cost = True + if benchmark_cost is not None: + total_run_cost = (total_run_cost or 0.0) + float(benchmark_cost) + had_cost = True + if not had_cost: + total_run_cost = None + + completed_sessions = [s for s in sessions.values() if s.get("status") != "running"] + completed_total = len(completed_sessions) + + success_rate = percent_successful + if success_rate is None and completed_total > 0: + success_rate = sum(1 for s in completed_sessions if s.get("status") == "success") / completed_total + + finished_rate = percent_finished + if finished_rate is None and completed_total > 0: + finished_rate = ( + sum(1 for s in completed_sessions if s.get("status") in ("success", "unsuccessful")) / completed_total + ) + + finished_unsuccessful_rate = percent_finished_unsuccessful + if finished_unsuccessful_rate is None and completed_total > 0: + finished_unsuccessful_rate = ( + sum(1 for s in completed_sessions if s.get("status") == "unsuccessful") / completed_total + ) + + unfinished_rate = percent_unfinished + if unfinished_rate is None and completed_total > 0: + unfinished_rate = sum(1 for s in completed_sessions if s.get("status") == "unfinished") / completed_total + + error_rate = percent_error + if error_rate is None and completed_total > 0: + error_rate = ( + sum( + 1 + for s in completed_sessions + if s.get("status") in ("error", "agent error", "benchmark error", "cancelled") + ) + / completed_total + ) + + return [ + ("Avg Score", avg_score), + ("Avg Steps", avg_steps), + ("Avg Agent Cost", avg_agent_cost), + ("Avg Benchmark Cost", avg_benchmark_cost), + ("Total Run Cost", total_run_cost), + ] + + +def load_leaderboard_data(output_dir: str) -> list[dict]: + rows = [] + try: + for run_id in os.listdir(output_dir): + results_path = RunPaths(run_id=run_id, output_dir=output_dir).results + if not os.path.isfile(results_path): + continue + try: + with open(results_path, encoding="utf-8-sig") as f: + r = json.load(f) + rows.append( + { + "Agent": r.get("agent_name", "unknown"), + "Model": r.get("model_name", "unknown"), + "Benchmark": r.get("benchmark_name", "unknown"), + "Subset": r.get("subset_name", "unknown"), + "Num Tasks": r.get("total_sessions", 0), + "Final Score": r.get("average_score"), + "Total Run Cost": r.get("total_run_cost"), + "Avg Agent Cost": r.get("average_agent_cost"), + "run_id": run_id, + } + ) + except (OSError, json.JSONDecodeError): + continue + except FileNotFoundError: + pass + return rows + + +def load_run_results(results_path: str) -> Optional[dict]: + try: + with open(results_path, encoding="utf-8-sig") as f: + return json.load(f) + except (OSError, json.JSONDecodeError): + return None + + +def load_run_config(run_id: str, *, output_dir: Optional[str] = None) -> Optional[dict]: + resolved_output = output_dir or get_settings().output_dir + run_paths = RunPaths(run_id=run_id, output_dir=resolved_output) + config_path = run_paths.config + config_data = load_session_file(str(config_path), "json") + if config_data is not None: + return config_data + legacy_path = run_paths.root / "config.json" + if legacy_path == config_path: + return None + return load_session_file(str(legacy_path), "json") + + +def _short_model_name(name: Any) -> str: + text = str(name) + if "/" in text: + return text.split("/")[-1] + return text + + +def _resolve_run_meta( + results: Optional[dict], + config_data: Optional[dict], + *, + fallback_benchmark: Optional[str] = None, + fallback_agent: Optional[str] = None, +) -> dict[str, Any]: + benchmark = None + agent = None + models: list[str] = [] + + if isinstance(results, dict): + benchmark = results.get("benchmark_name") or benchmark + agent = results.get("agent_name") or agent + model_names = results.get("model_names") or [] + if isinstance(model_names, list): + models = [_short_model_name(m) for m in model_names if m] + if not models and results.get("model_name"): + models = [_short_model_name(results.get("model_name"))] + + if isinstance(config_data, dict): + bench_cfg = config_data.get("benchmark") or {} + agent_cfg = config_data.get("agent") or {} + if isinstance(bench_cfg, str): + if not benchmark: + benchmark = bench_cfg + bench_cfg = {} + if isinstance(agent_cfg, str): + if not agent: + agent = agent_cfg + agent_cfg = {} + if not benchmark: + benchmark = bench_cfg.get("display_name") or bench_cfg.get("slug_name") or bench_cfg.get("class") + if not agent: + agent = agent_cfg.get("display_name") or agent_cfg.get("slug_name") or agent_cfg.get("class") + if not models: + model_names = agent_cfg.get("model_names") + if isinstance(model_names, list): + models = [_short_model_name(m) for m in model_names if m] + if not models and agent_cfg.get("model_name"): + models = [_short_model_name(agent_cfg.get("model_name"))] + + if not benchmark: + benchmark = fallback_benchmark + if not agent: + agent = fallback_agent + + if models: + models = list(dict.fromkeys(models)) + + return { + "benchmark": benchmark or "-", + "agent": agent or "-", + "models": models, + } + + +def _resolve_planned_sessions( + results: Optional[dict], + config_data: Optional[dict], + fallback: Optional[int], +) -> Optional[int]: + if isinstance(results, dict) and results.get("planned_sessions") is not None: + return results.get("planned_sessions") + if isinstance(config_data, dict): + if config_data.get("planned_sessions") is not None: + return config_data.get("planned_sessions") + if config_data.get("num_tasks") is not None: + return config_data.get("num_tasks") + + bench_cfg = config_data.get("benchmark") + if isinstance(bench_cfg, dict) and bench_cfg.get("planned_sessions") is not None: + return bench_cfg.get("planned_sessions") + + run_cfg = config_data.get("run") + if isinstance(run_cfg, dict): + if run_cfg.get("planned_sessions") is not None: + return run_cfg.get("planned_sessions") + if run_cfg.get("num_tasks") is not None: + return run_cfg.get("num_tasks") + return fallback + + +def _resolve_total_workers( + results: Optional[dict], + config_data: Optional[dict], + fallback: Optional[int], +) -> Optional[int]: + if isinstance(results, dict) and results.get("max_workers") is not None: + return results.get("max_workers") + if isinstance(config_data, dict): + if config_data.get("max_workers") is not None: + return config_data.get("max_workers") + + run_cfg = config_data.get("run") + if isinstance(run_cfg, dict) and run_cfg.get("max_workers") is not None: + return run_cfg.get("max_workers") + return fallback + + +def _load_run_context( + run_id: Optional[str], + *, + fallback_benchmark: Optional[str] = None, + fallback_agent: Optional[str] = None, + planned_fallback: Optional[int] = None, + workers_fallback: Optional[int] = None, + output_dir: Optional[str] = None, +) -> RunContext: + results = None + config_data = None + if run_id: + resolved_output = output_dir or get_settings().output_dir + results = load_run_results(str(RunPaths(run_id=run_id, output_dir=resolved_output).results)) + config_data = load_run_config(run_id, output_dir=resolved_output) + run_meta = _resolve_run_meta( + results, + config_data, + fallback_benchmark=fallback_benchmark, + fallback_agent=fallback_agent, + ) + planned_sessions = _resolve_planned_sessions(results, config_data, planned_fallback) + total_workers = _resolve_total_workers(results, config_data, workers_fallback) + return RunContext( + results=results, + config=config_data, + run_meta=run_meta, + planned_sessions=planned_sessions, + total_workers=total_workers, + ) + + +def _resolve_tab_label( + value: Any, + tab_by_name: dict[str, Any], + default: str, +) -> str: + if isinstance(value, str) and value in tab_by_name: + return value + for name, tab in tab_by_name.items(): + if value is tab: + return name + return default + + +def _open_session_from_row( + state, + scope: str, + row: dict, + refresh, +) -> None: + session_id = row.get("session") + if not session_id: + return + if scope == "history": + state.selected_history_session = session_id + else: + state.selected_session = session_id + state.active_tabs[scope] = "Sessions" + tabs = state.tabs_controls.get(scope) + tab_by_name = state.tabs_by_scope.get(scope) or {} + target = tab_by_name.get("Sessions") + if tabs is not None and target is not None: + tabs.value = target + refresh() + + +def _build_session_rows(sessions: dict) -> list[dict]: + return [ + { + "session": sid, + "status": data.get("status", ""), + "steps": data.get("steps", 0), + "score": data.get("score"), + } + for sid, data in sessions.items() + ] + + +def load_session_file(file_path: str, file_type: str) -> Optional[Any]: + if not os.path.isfile(file_path): + return None + try: + if file_type == "log": + with open(file_path, encoding="utf-8-sig", errors="replace", newline="") as f: + lines = f.readlines()[-200:] + return "".join(lines) if lines else "(empty)" + with open(file_path, encoding="utf-8-sig") as f: + return json.load(f) + except (OSError, json.JSONDecodeError): + return None + + +def _list_text_files(root: Path) -> list[Path]: + if not root.is_dir(): + return [] + files = [] + for name in sorted(os.listdir(root)): + path = root / name + if not path.is_file(): + continue + if path.suffix.lower() in {".txt", ".log", ".json"}: + files.append(path) + return files + + +def _load_text_file(path: Path) -> Optional[str]: + if not path.is_file(): + return None + suffix = path.suffix.lower() + if suffix == ".json": + data = load_session_file(str(path), "json") + if data is None: + return None + return json.dumps(data, ensure_ascii=False, indent=2) + if suffix in {".txt", ".log"}: + return load_session_file(str(path), "log") + return None + + +def _load_trajectory_events(path: Path) -> list[dict]: + if not path.is_file(): + return [] + events: list[dict] = [] + try: + with open(path, encoding="utf-8-sig") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + events.append(json.loads(line)) + except json.JSONDecodeError: + continue + except OSError: + return [] + return events + + +def _build_history_sessions( + run_id: str, + results: Optional[dict], + *, + output_dir: Optional[str] = None, +) -> dict[str, dict]: + sessions: dict[str, dict] = {} + session_results = results.get("session_results") if isinstance(results, dict) else None + if isinstance(session_results, list): + for item in session_results: + if not isinstance(item, dict): + continue + session_id = item.get("session_id") + if not session_id: + continue + success = item.get("success", False) + is_finished = item.get("is_finished") + details = item.get("details") or {} + session_metadata = details.get("session_metadata") or {} + error_source = session_metadata.get("error_source") + error_message = session_metadata.get("error") + sessions[session_id] = { + "status": _status_from_outcome(success, is_finished, error_source), + "success": bool(success), + "steps": item.get("steps", 0), + "score": item.get("score"), + "execution_time": item.get("execution_time"), + "agent_cost": item.get("agent_cost"), + "benchmark_cost": item.get("benchmark_cost"), + "is_finished": is_finished, + "error_source": error_source, + "error": error_message, + } + if sessions: + return sessions + + resolved_output = output_dir or get_settings().output_dir + run_paths = RunPaths(run_id=run_id, output_dir=resolved_output) + sessions_root = run_paths.sessions_root + if not os.path.isdir(sessions_root): + return sessions + for name in sorted(os.listdir(sessions_root)): + sess_paths = run_paths.session(name) + sess_results = sess_paths.results + sess_summary = sess_paths.summary + target_path = sess_results if sess_results.is_file() else sess_summary + if not target_path.is_file(): + continue + data = load_session_file(str(target_path), "json") + if not isinstance(data, dict): + continue + success = data.get("success", False) + is_finished = data.get("is_finished") + details = data.get("details") or {} + session_metadata = details.get("session_metadata") or {} + error_source = session_metadata.get("error_source") + error_message = session_metadata.get("error") + sessions[name] = { + "status": _status_from_outcome(success, is_finished, error_source), + "success": bool(success), + "steps": data.get("steps", 0), + "score": data.get("score"), + "execution_time": data.get("execution_time"), + "agent_cost": data.get("agent_cost"), + "benchmark_cost": data.get("benchmark_cost"), + "is_finished": is_finished, + "error_source": error_source, + "error": error_message, + } + return sessions + + +def _load_history_turns( + run_id: str, + session_id: str, + *, + output_dir: Optional[str] = None, +) -> list[dict]: + resolved_output = output_dir or get_settings().output_dir + sess_paths = RunPaths(run_id=run_id, output_dir=resolved_output).session(session_id) + events = _load_trajectory_events(sess_paths.trajectory) + turns: list[dict] = [] + for event in events: + kind = event.get("event") or event.get("type") or "event" + if kind == "action": + payload = event.get("action") + elif kind == "observation": + payload = event.get("observation") + elif kind == "error": + payload = event.get("error") or event + else: + payload = event + turns.append({"type": kind, "step": event.get("step"), "content": payload}) + return turns[-200:] diff --git a/labs/AgentStream/exgentic/src/exgentic/interfaces/dashboard/views/formatting.py b/labs/AgentStream/exgentic/src/exgentic/interfaces/dashboard/views/formatting.py new file mode 100644 index 00000000..8065bf4a --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/interfaces/dashboard/views/formatting.py @@ -0,0 +1,99 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import json +import math +from typing import Any + + +def _format_value(value: Any) -> str: + if value is None: + return "-" + if isinstance(value, float): + if math.isnan(value): + return "NaN" + abs_val = abs(value) + if abs_val >= 100 or value.is_integer(): + return f"{value:.2f}".rstrip("0").rstrip(".") + if abs_val >= 1: + return f"{value:.3f}".rstrip("0").rstrip(".") + return f"{value:.6f}".rstrip("0").rstrip(".") + return str(value) + + +def _format_payload(value: Any, limit: int = 4000) -> str: + try: + payload = json.dumps(value, ensure_ascii=False, indent=2, default=str) + except Exception: + payload = str(value) + if len(payload) > limit: + return payload[:limit] + "..." + return payload + + +def _format_error_message(value: Any, limit: int = 4000) -> str: + if isinstance(value, str): + if len(value) > limit: + return value[:limit] + "..." + return value + return _format_payload(value, limit=limit) + + +def _normalize_action_payload(payload: Any) -> dict: + if payload is None: + return {"name": None, "arguments": None} + if isinstance(payload, dict): + action_type = payload.get("type") + if action_type in {"parallel", "sequential"}: + actions = payload.get("actions") or [] + cleaned_actions = [] + if isinstance(actions, list): + for item in actions: + if not isinstance(item, dict): + cleaned_actions.append({"name": str(item), "arguments": None}) + continue + cleaned_actions.append( + { + "name": item.get("name"), + "arguments": item.get("arguments"), + } + ) + return {"mode": action_type, "actions": cleaned_actions} + return { + "name": payload.get("name"), + "arguments": payload.get("arguments"), + } + return {"raw": payload} + + +def _normalize_observation_item(item: Any) -> Any: + if item is None: + return None + if isinstance(item, dict): + if "result" in item: + result = item.get("result") + if isinstance(result, dict): + if "sender" in result and "message" in result: + return { + "sender": result.get("sender"), + "message": result.get("message"), + } + return result + if "sender" in item and "message" in item: + return {"sender": item.get("sender"), "message": item.get("message")} + return item + return item + + +def _normalize_observation_payload(payload: Any) -> Any: + if payload is None: + return None + if isinstance(payload, dict): + if isinstance(payload.get("observations"), list): + return [_normalize_observation_item(item) for item in payload["observations"]] + return _normalize_observation_item(payload) + if isinstance(payload, list): + return [_normalize_observation_item(item) for item in payload] + return payload diff --git a/labs/AgentStream/exgentic/src/exgentic/interfaces/dashboard/views/forms.py b/labs/AgentStream/exgentic/src/exgentic/interfaces/dashboard/views/forms.py new file mode 100644 index 00000000..b42a8f27 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/interfaces/dashboard/views/forms.py @@ -0,0 +1,134 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import json +import types +from typing import Any, Literal, Union, get_args, get_origin + +from nicegui import ui + +try: + from pydantic.fields import PydanticUndefined +except Exception: # pragma: no cover + PydanticUndefined = None + + +def _is_optional(ann: Any) -> tuple[bool, Any]: + origin = get_origin(ann) + if origin in (Union, types.UnionType): + args = list(get_args(ann)) + if len(args) == 2 and type(None) in args: + base = args[0] if args[1] is type(None) else args[1] + return True, base + return False, ann + + +def _build_pydantic_form(model_cls: type[Any], disabled: bool) -> dict[str, Any]: + controls: dict[str, Any] = {} + fields = model_cls.model_fields + schema = model_cls.model_json_schema() + schema_props: dict[str, Any] = schema.get("properties", {}) + + with ui.column().classes("w-full form-stack"): + for name, form_field in fields.items(): + if name.startswith("_"): + continue + ann = form_field.annotation + default = form_field.default + if PydanticUndefined is not None and default is PydanticUndefined: + default = None + is_opt, base = _is_optional(ann) + origin = get_origin(base) + + options = None + prop = schema_props.get(name) or {} + if isinstance(prop, dict): + if "enum" in prop: + options = list(prop["enum"]) + elif "anyOf" in prop: + any_of = prop["anyOf"] + if isinstance(any_of, list): + for sub in any_of: + if isinstance(sub, dict) and "enum" in sub: + options = list(sub["enum"]) + break + elif "const" in prop: + options = [prop["const"]] + if origin is Literal and options is None: + options = list(get_args(base)) + + if options: + shown = ["", *options] if is_opt and default is None else options + value = default if default in shown else (shown[0] if shown else None) + control = ui.select(shown, value=value, label=name).props("dense") + control.enabled = not disabled + controls[name] = ("select", control, is_opt) + elif base in (int, float): + value = default if default is not None else 0 + control = ui.number(label=name, value=value) + control.enabled = not disabled + controls[name] = ("number", control, is_opt, base) + elif base is bool: + value = bool(default) if default is not None else False + control = ui.checkbox(name, value=value) + control.enabled = not disabled + controls[name] = ("checkbox", control, is_opt) + elif (get_origin(base) is dict) or (base is dict): + init = json.dumps(default or {}, ensure_ascii=False, indent=2) + control = ui.textarea(label=name, value=init).props("rows=4") + control.enabled = not disabled + controls[name] = ("json", control, is_opt, "dict") + elif (get_origin(base) is list) or (base is list): + init = json.dumps(default or [], ensure_ascii=False, indent=2) + control = ui.textarea(label=name, value=init).props("rows=4") + control.enabled = not disabled + controls[name] = ("json", control, is_opt, "list") + else: + value = "" if default is None else str(default) + control = ui.input(label=name, value=value) + control.enabled = not disabled + controls[name] = ("text", control, is_opt) + + return controls + + +def _build_agent_form(agent_cls: type[Any], disabled: bool) -> dict[str, Any]: + return _build_pydantic_form(agent_cls, disabled) + + +def _collect_values(controls: dict[str, Any]) -> dict[str, Any]: + values: dict[str, Any] = {} + for name, data in controls.items(): + kind = data[0] + control = data[1] + is_opt = data[2] if len(data) > 2 else False + raw = control.value + if kind == "select": + if is_opt and (raw is None or raw == ""): + values[name] = None + else: + values[name] = raw + elif kind == "number": + base = data[3] + if raw is None and is_opt: + values[name] = None + else: + values[name] = base(raw) if raw is not None else 0 + elif kind == "checkbox": + values[name] = bool(raw) + elif kind == "json": + if raw is None or raw == "": + values[name] = {} if data[3] == "dict" else [] + else: + try: + values[name] = json.loads(raw) + except Exception: + values[name] = {} if data[3] == "dict" else [] + else: + if is_opt and (raw is None or raw == ""): + values[name] = None + else: + values[name] = raw + return values diff --git a/labs/AgentStream/exgentic/src/exgentic/interfaces/dashboard/views/panels.py b/labs/AgentStream/exgentic/src/exgentic/interfaces/dashboard/views/panels.py new file mode 100644 index 00000000..f8841927 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/interfaces/dashboard/views/panels.py @@ -0,0 +1,505 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Optional + +from nicegui import ui + +from ....utils.paths import RunPaths +from ....utils.settings import get_settings +from .data import ( + _build_session_rows, + _list_text_files, + _load_text_file, + load_run_config, + load_session_file, +) +from .formatting import ( + _format_error_message, + _format_payload, + _format_value, + _normalize_action_payload, + _normalize_observation_payload, +) +from .state import SESSION_COLUMNS +from .status import _render_status_pie, _status_counts_from_sessions + + +def _metric_help_text(label: str) -> Optional[str]: + return { + "Benchmark": "Benchmark used for this run.", + "Agent": "Agent used for this run.", + "Models": "LLMs used in this run.", + "Model": "Primary model used in this run.", + "Subset": "Benchmark subset for this run.", + "Running": "Currently running sessions / worker capacity.", + "Completed": "Finished sessions / planned sessions.", + "Total Sessions": "Total sessions recorded for this run.", + "Successful": "Number of successful sessions.", + "Benchmark Score": "Aggregate benchmark score for the run.", + "Avg Score": "Average score across completed sessions.", + "Avg Steps": "Average steps per session.", + "Avg Agent Cost": "Average agent cost per session.", + "Avg Benchmark Cost": "Average benchmark cost per session.", + "Total Agent Cost": "Total agent cost across sessions.", + "Total Benchmark Cost": "Total benchmark cost across sessions.", + "Total Run Cost": "Total agent + benchmark cost.", + "Finished": "Share of sessions marked finished.", + "Success Rate": "Share of successful sessions.", + "Finished Rate": "Share of sessions marked finished.", + "Success %": "Share of successful sessions.", + "Finished %": "Share of sessions marked finished.", + "Status": ( + "Session status (running/success/unsuccessful/unfinished/" "agent error/benchmark error/cancelled/error)." + ), + "Steps": "Number of actions taken in the session.", + "Score": "Session score from the benchmark.", + "Exec Time (s)": "Elapsed time for the session.", + "Agent Cost": "Cost attributed to the agent.", + "Benchmark Cost": "Cost attributed to the benchmark.", + }.get(label) + + +def _metric_info(help_text: str) -> None: + ui.icon("info").classes("metric-info").tooltip(help_text) + + +def _metric_card(label: str, value: Any) -> None: + with ui.element("div").classes("metric-card"): + help_text = _metric_help_text(label) + if help_text: + _metric_info(help_text) + ui.label(label).classes("metric-label") + ui.label(_format_value(value)).classes("metric-value") + + +def _metric_card_small(label: str, value: Any) -> None: + display_value = value if isinstance(value, str) else _format_value(value) + with ui.element("div").classes("metric-card metric-card-sm"): + help_text = _metric_help_text(label) + if help_text: + _metric_info(help_text) + ui.label(label).classes("metric-label") + ui.label(display_value).classes("metric-value") + + +def _metric_card_large(label: str, value: Any) -> None: + display_value = value if isinstance(value, str) else _format_value(value) + with ui.element("div").classes("metric-card metric-card-lg"): + help_text = _metric_help_text(label) + if help_text: + _metric_info(help_text) + ui.label(label).classes("metric-label") + ui.label(display_value).classes("metric-value") + + +def _render_run_log(run_id: Optional[str], *, output_dir: Optional[str] = None) -> None: + if not run_id: + ui.label("No run log found.") + return + resolved_output = output_dir or get_settings().output_dir + run_paths = RunPaths(run_id=run_id, output_dir=resolved_output) + log_content = load_session_file(str(run_paths.tracker), "log") + if log_content is None: + legacy_path = run_paths.root / "tracker.log" + if legacy_path != run_paths.tracker: + log_content = load_session_file(str(legacy_path), "log") + if log_content is None: + ui.label("No run log found.") + return + ui.code(log_content, language="text").classes("w-full") + + +def _render_action_entry(payload: Any) -> None: + data = _normalize_action_payload(payload) + if "raw" in data: + ui.code(_format_payload(data["raw"]), language="json").classes("w-full") + return + if "actions" in data: + mode = str(data.get("mode", "parallel")).title() + actions = data.get("actions") or [] + ui.label(f"{mode} actions ({len(actions)})").classes("metric") + if not actions: + ui.label("No actions provided.") + return + with ui.column().classes("w-full gap-3"): + for idx, item in enumerate(actions, start=1): + name = item.get("name") or f"Action {idx}" + args = item.get("arguments") + with ui.element("div").classes("trajectory-box"): + ui.label(name).classes("trajectory-title") + if args is None: + ui.label("No arguments.") + else: + ui.code(_format_payload(args), language="json").classes("w-full") + return + name = data.get("name") or "Action" + args = data.get("arguments") + if data.get("name") is None and data.get("arguments") is None: + ui.label("Empty action.") + return + with ui.element("div").classes("trajectory-box"): + ui.label(name).classes("trajectory-title") + if args is None: + ui.label("No arguments.") + else: + ui.code(_format_payload(args), language="json").classes("w-full") + + +def _render_observation_entry(payload: Any) -> None: + data = _normalize_observation_payload(payload) + if data is None: + ui.label("Empty observation.") + return + if isinstance(data, list): + ui.label(f"Observations ({len(data)})").classes("metric") + if not data: + ui.label("No observations provided.") + return + with ui.column().classes("w-full gap-3"): + for item in data: + if item is None: + ui.label("Empty observation.") + continue + if isinstance(item, dict) and "sender" in item and "message" in item: + sender = item.get("sender") or "Sender" + message = item.get("message") or "" + with ui.element("div").classes("trajectory-box"): + ui.label(str(sender)).classes("trajectory-title") + ui.code(str(message), language="text").classes("w-full") + continue + with ui.element("div").classes("trajectory-box"): + ui.code(_format_payload(item), language="json").classes("w-full") + return + if isinstance(data, dict) and "sender" in data and "message" in data: + sender = data.get("sender") or "Sender" + message = data.get("message") or "" + with ui.element("div").classes("trajectory-box"): + ui.label(str(sender)).classes("trajectory-title") + ui.code(str(message), language="text").classes("w-full") + return + ui.code(_format_payload(data), language="json").classes("w-full") + + +def _render_trajectory_entry(kind: str, payload: Any) -> None: + if kind == "action": + _render_action_entry(payload) + return + if kind == "observation": + _render_observation_entry(payload) + return + if kind == "error": + ui.code(_format_payload(payload), language="json").classes("w-full") + return + ui.code(_format_payload(payload), language="json").classes("w-full") + + +def _render_overview_panel( + sessions: dict, + *, + run_active: bool, + run_id: Optional[str], + results: Optional[dict] = None, + planned_sessions: int | None = None, + total_workers: int | None = None, + run_meta: Optional[dict[str, Any]] = None, + on_open_session=None, +) -> None: + if run_meta: + models = run_meta.get("models") or [] + models_text = ", ".join(models) if models else "-" + with ui.element("div").classes("w-full metric-grid"): + _metric_card_large("Benchmark", run_meta.get("benchmark")) + _metric_card_large("Agent", run_meta.get("agent")) + _metric_card_large("Models", models_text) + if not sessions: + if run_active: + ui.label("Waiting for sessions...").classes("text-sm muted") + else: + ui.label("No sessions found.") + return + total = len(sessions) + running = sum(1 for s in sessions.values() if s.get("status") == "running") + done = total - running + if planned_sessions is None: + planned_sessions = total + running_total = total_workers if total_workers is not None else "-" + benchmark_score = results.get("benchmark_score") if isinstance(results, dict) else None + with ui.element("div").classes("w-full metric-grid"): + _metric_card("Running", f"{running}/{running_total}") + _metric_card("Completed", f"{done}/{planned_sessions}") + _metric_card("Benchmark Score", _format_value(benchmark_score)) + + from .data import _build_overview_secondary_metrics + + secondary_metrics = _build_overview_secondary_metrics(sessions, results) + with ui.element("div").classes("w-full metric-grid"): + for label, value in secondary_metrics: + _metric_card_small(label, value) + + with ui.card().classes("w-full card p-4"): + ui.label("Status Breakdown").classes("section-title") + status_counts = _status_counts_from_sessions(sessions) + _render_status_pie(status_counts) + + with ui.card().classes("w-full card p-4"): + rows = _build_session_rows(sessions) + selection = "single" if on_open_session else None + + def _handle_select(e) -> None: + selection_rows = getattr(e, "selection", None) + if not selection_rows: + return + row = selection_rows[-1] + if isinstance(row, dict): + on_open_session(row) + + ( + ui.table( + columns=SESSION_COLUMNS, + rows=rows, + row_key="session", + selection=selection, + on_select=_handle_select if on_open_session else None, + ) + .classes("w-full") + .props("flat") + ) + + +def _render_tabs( + labels: list[str], + renderers: dict[str, Any], + *, + default: Optional[str] = None, +) -> None: + default_label = default or labels[0] + with ui.tabs() as tabs: + tab_by_name = {label: ui.tab(label) for label in labels} + + with ui.tab_panels(tabs, value=tab_by_name[default_label]).classes("w-full"): + for label in labels: + with ui.tab_panel(tab_by_name[label]): + renderers[label]() + + +def _render_session_results_overview(meta: dict) -> None: + with ui.element("div").classes("w-full metric-grid"): + _metric_card("Status", meta.get("status", "-")) + _metric_card("Steps", meta.get("steps", 0)) + _metric_card("Score", meta.get("score", "-")) + error_msg = meta.get("error") + if error_msg: + ui.label("Error Details").classes("section-title") + ui.code(_format_error_message(error_msg), language="text").classes("w-full").style("white-space: pre-wrap;") + + with ui.element("div").classes("w-full metric-grid"): + _metric_card_small("Exec Time (s)", meta.get("execution_time")) + _metric_card_small("Agent Cost", meta.get("agent_cost")) + _metric_card_small("Benchmark Cost", meta.get("benchmark_cost")) + + +def _render_session_trajectory(turns_list: list[dict]) -> None: + if not turns_list: + ui.label("No trajectory data available yet.") + return + with ui.timeline(side="right"): + for item in turns_list: + kind = str(item.get("type", "event")) + step_no = item.get("step", "?") + content = item.get("content") + title = kind.title() + subtitle = f"Step {step_no}" if step_no != "?" else None + icon = None + if kind == "action": + icon = "bolt" + elif kind == "observation": + icon = "visibility" + elif kind == "error": + icon = "error" + with ui.timeline_entry(title=title, subtitle=subtitle, icon=icon): + _render_trajectory_entry(kind, content) + + +def _render_session_logs( + run_id: Optional[str], + agent_files: list[Path], + benchmark_files: list[Path], +) -> None: + if not run_id: + ui.label("No logs found.") + return + + def _render_agent_logs() -> None: + _render_log_files(agent_files) + + def _render_benchmark_logs() -> None: + _render_log_files(benchmark_files) + + _render_tabs( + ["Agent", "Benchmark"], + {"Agent": _render_agent_logs, "Benchmark": _render_benchmark_logs}, + ) + + +def _render_session_config( + run_config_content: Optional[dict], + benchmark_config_content: Optional[dict], +) -> None: + if run_config_content is None and benchmark_config_content is None: + ui.label("No config found.") + return + if run_config_content is not None: + with ui.expansion("Run Config", value=False): + ui.code(_format_payload(run_config_content), language="json").classes("w-full") + if benchmark_config_content is not None: + with ui.expansion("Benchmark Config", value=False): + ui.code(_format_payload(benchmark_config_content), language="json").classes("w-full") + + +def _render_session_benchmark_results(results_content: Optional[dict]) -> None: + if results_content is None: + ui.label("No results found.") + else: + ui.code(_format_payload(results_content), language="json").classes("w-full") + + +def _render_session_tabs( + *, + meta: dict, + session_data: Optional[dict], + turns_list: list[dict], + run_id: Optional[str], + agent_files: list[Path], + benchmark_files: list[Path], + run_config_content: Optional[dict], + benchmark_config_content: Optional[dict], + results_content: Optional[dict], +) -> None: + def _render_results_tab() -> None: + _render_session_results_overview(meta) + + def _render_task_tab() -> None: + _render_task_details(session_data) + + def _render_trajectory_tab() -> None: + _render_session_trajectory(turns_list) + + def _render_logs_tab() -> None: + _render_session_logs(run_id, agent_files, benchmark_files) + + def _render_config_tab() -> None: + _render_session_config(run_config_content, benchmark_config_content) + + def _render_benchmark_results_tab() -> None: + _render_session_benchmark_results(results_content) + + _render_tabs( + ["Results", "Task", "Trajectory", "Logs", "Config", "Benchmark Results"], + { + "Results": _render_results_tab, + "Task": _render_task_tab, + "Trajectory": _render_trajectory_tab, + "Logs": _render_logs_tab, + "Config": _render_config_tab, + "Benchmark Results": _render_benchmark_results_tab, + }, + default="Results", + ) + + +def _render_dive_panel( + sessions: dict, + turns: dict, + *, + run_id: Optional[str], + selected_session: Optional[str], + on_session_change, + output_dir: Optional[str] = None, +) -> None: + session_ids = sorted(sessions.keys()) + if not session_ids: + ui.label("No sessions to inspect yet.") + return + + active_session = selected_session if selected_session in session_ids else session_ids[0] + + with ui.card().classes("w-full card p-4"): + ui.select(session_ids, value=active_session, label="Select session").props("dense").on_value_change( + on_session_change + ) + meta = sessions.get(active_session, {}) + turns_list = turns.get(active_session, []) + + agent_files: list[Path] = [] + benchmark_files: list[Path] = [] + session_data = None + run_config_content = None + benchmark_config_content = None + results_content = None + if run_id: + resolved_output = output_dir or get_settings().output_dir + sess_paths = RunPaths(run_id=run_id, output_dir=resolved_output).session(active_session) + base = sess_paths.benchmark_dir + agent_files = _list_text_files(sess_paths.agent_dir) + benchmark_files = _list_text_files(base) + session_data = load_session_file(str(sess_paths.session_manifest), "json") + run_config_content = load_run_config(run_id, output_dir=resolved_output) + benchmark_config_content = load_session_file(str(base / "config.json"), "json") + results_content = load_session_file(str(sess_paths.benchmark_results), "json") + + with ui.card().classes("w-full card p-4"): + _render_session_tabs( + meta=meta, + session_data=session_data, + turns_list=turns_list, + run_id=run_id, + agent_files=agent_files, + benchmark_files=benchmark_files, + run_config_content=run_config_content, + benchmark_config_content=benchmark_config_content, + results_content=results_content, + ) + + +def _render_log_files(files: list[Path]) -> None: + if not files: + ui.label("No logs found.") + return + for path in files: + with ui.expansion(path.name, value=False): + content = _load_text_file(path) + if content is None: + ui.label("Unable to read file.") + else: + language = "json" if path.suffix.lower() == ".json" else "text" + ui.code(content, language=language).classes("w-full") + + +def _render_task_details(session_data: Optional[dict]) -> None: + if not session_data: + ui.label("No session data found.") + return + ordered_keys = ["task", "context", "actions"] + seen = set() + for key in ordered_keys: + if key in session_data: + seen.add(key) + with ui.expansion(str(key), value=False): + value = session_data.get(key) + if isinstance(value, str): + ui.code(value, language="text").classes("w-full") + else: + ui.code(_format_payload(value), language="json").classes("w-full") + for key in sorted(session_data.keys()): + if key in seen: + continue + with ui.expansion(str(key), value=False): + value = session_data.get(key) + if isinstance(value, str): + ui.code(value, language="text").classes("w-full") + else: + ui.code(_format_payload(value), language="json").classes("w-full") diff --git a/labs/AgentStream/exgentic/src/exgentic/interfaces/dashboard/views/runtime.py b/labs/AgentStream/exgentic/src/exgentic/interfaces/dashboard/views/runtime.py new file mode 100644 index 00000000..9514c490 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/interfaces/dashboard/views/runtime.py @@ -0,0 +1,895 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import logging +import os +import threading +from typing import Any + +from nicegui import ui + +from ....core.context import run_scope +from ....core.types import RunConfig +from ....observers.handlers.dashboard_events import DashboardEventsObserver +from ....utils.paths import RunPaths +from ....utils.settings import get_settings +from ...lib.api import ( + evaluate, + get_agent_info, + get_benchmark_info, + load_agent_class, + load_benchmark_class, +) +from .data import ( + _build_history_sessions, + _load_history_turns, + _load_run_context, + _open_session_from_row, + _resolve_tab_label, + get_display_mappings, + load_leaderboard_data, +) +from .forms import _build_agent_form, _build_pydantic_form, _collect_values +from .panels import ( + _render_dive_panel, + _render_overview_panel, + _render_run_log, +) +from .state import LEADERBOARD_COLUMNS, RunState, RunViews +from .status import _status_from_outcome + +_LOG = logging.getLogger("exgentic.interfaces.dashboard") +if not _LOG.handlers: + _handler = logging.StreamHandler() + _handler.setFormatter(logging.Formatter("Dashboard | %(message)s")) + _LOG.addHandler(_handler) +_LOG.setLevel(logging.INFO) +_LOG.propagate = False + + +def _init_tabs( + state: RunState, + scope: str, + labels: list[str], +) -> tuple[Any, dict[str, Any], Any]: + if scope not in state.active_tabs: + state.active_tabs[scope] = labels[0] + + with ui.tabs() as tabs: + tab_by_name = {label: ui.tab(label) for label in labels} + + state.tabs_controls[scope] = tabs + state.tabs_by_scope[scope] = tab_by_name + + def _on_tab_change(e) -> None: + value = getattr(e, "value", None) + state.active_tabs[scope] = _resolve_tab_label(value, tab_by_name, labels[0]) + + tabs.on_value_change(_on_tab_change) + + active_label = state.active_tabs.get(scope, labels[0]) + active_tab = tab_by_name.get(active_label, tab_by_name[labels[0]]) + return tabs, tab_by_name, active_tab + + +def process_new_events(state: RunState) -> bool: + tracker = state.tracker + if tracker is None: + return False + + events_to_process = [] + event_count = 0 + max_events = 40 + + while not tracker.events.empty() and event_count < max_events: + try: + evt = tracker.events.get_nowait() + events_to_process.append(evt) + event_count += 1 + except Exception: + break + + if not events_to_process: + return False + + sessions = state.sessions + turns = state.turns + + for evt in events_to_process: + state.events.append(evt) + et = evt.get("type") + + if et == "session_started": + sid = evt.get("session_id") + if sid: + sessions[sid] = { + "status": "running", + "steps": 0, + "success": None, + "is_finished": None, + "error_source": None, + } + + elif et == "step": + sid = evt.get("session_id") + if sid and sid in sessions: + sessions[sid]["steps"] = sessions[sid].get("steps", 0) + 1 + if "execution_time" in evt: + sessions[sid]["execution_time"] = evt.get("execution_time") + if "agent_cost" in evt: + sessions[sid]["agent_cost"] = evt.get("agent_cost") + if "benchmark_cost" in evt: + sessions[sid]["benchmark_cost"] = evt.get("benchmark_cost") + step_no = evt.get("n") + act_obj = evt.get("action_obj") + if act_obj is not None: + turns.setdefault(sid, []).append({"type": "action", "step": step_no, "content": act_obj}) + turns[sid] = turns[sid][-200:] + + elif et == "observation": + sid = evt.get("session_id") + if sid and sid in sessions: + step_no = evt.get("step") + obs_obj = evt.get("observation") + if "execution_time" in evt: + sessions[sid]["execution_time"] = evt.get("execution_time") + if "agent_cost" in evt: + sessions[sid]["agent_cost"] = evt.get("agent_cost") + if "benchmark_cost" in evt: + sessions[sid]["benchmark_cost"] = evt.get("benchmark_cost") + if obs_obj is not None: + turns.setdefault(sid, []).append({"type": "observation", "step": step_no, "content": obs_obj}) + turns[sid] = turns[sid][-200:] + + elif et == "session_finished": + sid = evt.get("session_id") + if sid and sid in sessions: + success = evt.get("success", False) + is_finished = evt.get("is_finished") + error_source = evt.get("error_source") + sessions[sid]["status"] = _status_from_outcome(success, is_finished, error_source) + sessions[sid]["success"] = success + sessions[sid]["score"] = evt.get("score") + if "steps" in evt: + sessions[sid]["steps"] = evt.get("steps") + if "execution_time" in evt: + sessions[sid]["execution_time"] = evt.get("execution_time") + if "agent_cost" in evt: + sessions[sid]["agent_cost"] = evt.get("agent_cost") + if "benchmark_cost" in evt: + sessions[sid]["benchmark_cost"] = evt.get("benchmark_cost") + if "is_finished" in evt: + sessions[sid]["is_finished"] = is_finished + if "error_source" in evt: + sessions[sid]["error_source"] = error_source + if "details" in evt: + details = evt.get("details") or {} + sessions[sid]["error"] = details.get("error") + if not success: + details = evt.get("details") or {} + turns.setdefault(sid, []).append( + { + "type": "error", + "step": sessions[sid].get("steps", 0), + "content": details or "Session ended with an error.", + } + ) + + elif et == "saved": + ctx_mgr = state.run_context_manager + if ctx_mgr is not None: + try: + ctx_mgr.__exit__(None, None, None) + except Exception: + pass + state.run_context_manager = None + state.run_active = False + state.refresh_needed = True + _LOG.info("run saved; dashboard state updated") + + elif et == "run_meta": + run_id = evt.get("run_id") + if run_id: + state.run_id = run_id + _LOG.info("run_meta received: %s", run_id) + + return True + + +@ui.refreshable +def bench_form_panel(state: RunState) -> None: + bench_key = state.bench_key + if bench_key is None: + ui.label("No benchmark selected") + return + try: + bench_cls = load_benchmark_class(bench_key) + except Exception as exc: + ui.label(f"Failed to load benchmark '{bench_key}': {exc}") + return + state.bench_controls = _build_pydantic_form(bench_cls, disabled=state.run_active) + + +@ui.refreshable +def agent_form_panel(state: RunState) -> None: + agent_key = state.agent_key + if agent_key is None: + ui.label("No agent selected") + return + try: + agent_cls = load_agent_class(agent_key) + except Exception as exc: + ui.label(f"Failed to load agent '{agent_key}': {exc}") + return + state.agent_controls = _build_agent_form(agent_cls, disabled=state.run_active) + + +@ui.refreshable +def run_log_panel(state: RunState) -> None: + _render_run_log(state.run_id) + + +@ui.refreshable +def overview_panel(state: RunState) -> None: + fallback_benchmark = None + if state.bench_key: + try: + fallback_benchmark = get_benchmark_info(state.bench_key)["display_name"] + except Exception: + fallback_benchmark = None + fallback_agent = None + if state.agent_key: + try: + fallback_agent = get_agent_info(state.agent_key)["display_name"] + except Exception: + fallback_agent = None + context = _load_run_context( + state.run_id, + fallback_benchmark=fallback_benchmark, + fallback_agent=fallback_agent, + planned_fallback=state.planned_sessions, + workers_fallback=state.max_workers if state.run_active else None, + ) + + def _open_session(row: dict) -> None: + _open_session_from_row(state, "run", row, dive_panel.refresh) + + _render_overview_panel( + state.sessions, + run_active=state.run_active, + run_id=state.run_id, + results=context.results, + planned_sessions=context.planned_sessions, + total_workers=context.total_workers, + run_meta=context.run_meta, + on_open_session=_open_session, + ) + + +@ui.refreshable +def dive_panel(state: RunState) -> None: + sessions = sorted(state.sessions.keys()) + if not sessions: + ui.label("No sessions to inspect yet.") + return + + if state.selected_session not in sessions: + state.selected_session = sessions[0] + + def on_session_change(e) -> None: + state.selected_session = e.value + dive_panel.refresh() + + _render_dive_panel( + state.sessions, + state.turns, + run_id=state.run_id, + selected_session=state.selected_session, + on_session_change=on_session_change, + ) + + +@ui.refreshable +def leaderboard_panel(state: RunState) -> None: + settings = get_settings() + rows = load_leaderboard_data(settings.output_dir) + rows = sorted(rows, key=lambda r: r.get("run_id", ""), reverse=True) + if not rows: + ui.label("No runs found yet.") + return + + def _norm(value: object) -> str: + if value is None: + return "unknown" + text = str(value).strip() + return text or "unknown" + + agents = sorted({_norm(row.get("Agent")) for row in rows}) + models = sorted({_norm(row.get("Model")) for row in rows}) + benchmarks = sorted({_norm(row.get("Benchmark")) for row in rows}) + subsets = sorted({_norm(row.get("Subset")) for row in rows}) + + if not state.selected_agents: + state.selected_agents = agents + if not state.selected_models: + state.selected_models = models + if not state.selected_benchmarks: + state.selected_benchmarks = benchmarks + if not state.selected_subsets: + state.selected_subsets = subsets + + def on_agents_change(e) -> None: + state.selected_agents = e.value or [] + leaderboard_panel.refresh() + + def on_models_change(e) -> None: + state.selected_models = e.value or [] + leaderboard_panel.refresh() + + def on_benchmarks_change(e) -> None: + state.selected_benchmarks = e.value or [] + leaderboard_panel.refresh() + + def on_subsets_change(e) -> None: + state.selected_subsets = e.value or [] + leaderboard_panel.refresh() + + def on_min_tasks_change(e) -> None: + try: + state.min_tasks = int(e.value) + except Exception: + state.min_tasks = 0 + leaderboard_panel.refresh() + + with ui.card().classes("w-full card p-4"): + with ui.row().classes("w-full"): + ui.select( + agents, + value=state.selected_agents, + label="Agent", + multiple=True, + ).props("dense").on_value_change(on_agents_change) + ui.select( + models, + value=state.selected_models, + label="Model", + multiple=True, + ).props("dense").on_value_change(on_models_change) + ui.select( + benchmarks, + value=state.selected_benchmarks, + label="Benchmark", + multiple=True, + ).props("dense").on_value_change(on_benchmarks_change) + ui.select( + subsets, + value=state.selected_subsets, + label="Subset", + multiple=True, + ).props("dense").on_value_change(on_subsets_change) + ui.number( + label="Min Tasks", + value=state.min_tasks, + min=0, + step=1, + ).props("dense").on_value_change(on_min_tasks_change) + + def _include(row: dict) -> bool: + if row.get("Agent", "unknown") not in state.selected_agents: + return False + if row.get("Model", "unknown") not in state.selected_models: + return False + if row.get("Benchmark", "unknown") not in state.selected_benchmarks: + return False + if row.get("Subset", "unknown") not in state.selected_subsets: + return False + try: + return int(row.get("Num Tasks", 0)) >= state.min_tasks + except Exception: + return False + + filtered = [row for row in rows if _include(row)] + with ui.card().classes("w-full card p-4"): + ui.table(columns=LEADERBOARD_COLUMNS, rows=filtered, row_key="run_id").classes("w-full").props("flat") + + +@ui.refreshable +def history_panel(state: RunState) -> None: + settings = get_settings() + + def _normalize_history_root(value: str) -> str: + raw = (value or "").strip() + if not raw: + return settings.output_dir + return os.path.abspath(os.path.expanduser(raw)) + + history_root = _normalize_history_root(state.history_root) + runs = [] + if os.path.isdir(history_root): + for name in os.listdir(history_root): + results_path = RunPaths(run_id=name, output_dir=history_root).results + if os.path.isfile(results_path): + runs.append(name) + runs = sorted(runs, reverse=True) + + if state.selected_history_run not in runs: + state.selected_history_run = runs[0] if runs else None + state.selected_history_session = None + state.active_tabs["history"] = "Overview" + + def on_history_change(e) -> None: + state.selected_history_run = e.value + state.selected_history_session = None + state.active_tabs["history"] = "Overview" + history_panel.refresh() + + def on_history_root_change(e) -> None: + state.history_root = e.value or "" + state.selected_history_run = None + state.selected_history_session = None + state.active_tabs["history"] = "Overview" + history_panel.refresh() + + def open_history_browser() -> None: + browser_state = {"path": history_root} + + dialog = ui.dialog() + + def refresh_entries(container) -> None: + container.clear() + current = browser_state["path"] + with container: + if not os.path.isdir(current): + ui.label("Directory not found.").classes("text-negative") + return + parent = os.path.dirname(current.rstrip(os.sep)) + entries = [name for name in sorted(os.listdir(current)) if os.path.isdir(os.path.join(current, name))] + if parent and parent != current: + entries = ["..", *entries] + if not entries: + ui.label("(empty)") + return + for name in entries: + ui.button( + name, + on_click=lambda n=name: on_entry_click(n, container), + ).props("flat dense").classes("justify-start w-full") + + def on_entry_click(name: str, container) -> None: + current = browser_state["path"] + if name == "..": + parent = os.path.dirname(current.rstrip(os.sep)) + if parent and parent != current: + browser_state["path"] = parent + else: + browser_state["path"] = os.path.join(current, name) + path_input.value = browser_state["path"] + refresh_entries(container) + + def on_select() -> None: + if not os.path.isdir(browser_state["path"]): + ui.notify("Directory not found.") + return + state.history_root = browser_state["path"] + state.selected_history_run = None + state.selected_history_session = None + state.active_tabs["history"] = "Overview" + history_panel.refresh() + dialog.close() + + with dialog, ui.card().classes("w-[520px] max-w-full"): + ui.label("Select run directory") + with ui.row().classes("w-full items-center gap-2"): + path_input = ( + ui.input( + label="Directory", + value=browser_state["path"], + ) + .props("dense") + .style("flex: 1;") + ) + + entries_box = ui.column().classes("w-full gap-2").style("max-height: 320px; overflow-y: auto;") + refresh_entries(entries_box) + + def on_path_change(e) -> None: + browser_state["path"] = e.value or "" + refresh_entries(entries_box) + + path_input.on_value_change(on_path_change) + + with ui.row().classes("w-full justify-end gap-2"): + ui.button("Cancel", on_click=dialog.close).props("flat dense") + ui.button("Select", on_click=on_select).props("dense").style( + "background:#111111 !important; color:#ffffff !important;" + ) + + dialog.open() + + def on_history_session_change(e) -> None: + state.selected_history_session = e.value + state.active_tabs["history"] = "Sessions" + history_panel.refresh() + + with ui.card().classes("w-full card p-4"): + with ui.row().classes("w-full items-center gap-3"): + ui.input( + label="Run directory", + value=state.history_root or settings.output_dir, + placeholder=settings.output_dir, + ).props("dense").style("min-width: 360px;").on_value_change(on_history_root_change) + ui.button(icon="folder_open").props("dense flat").on_click(open_history_browser) + ui.select(runs, value=state.selected_history_run, label="Select run").props("dense").style( + "min-width: 280px;" + ).on_value_change(on_history_change) + if not os.path.isdir(history_root): + ui.label(f"Directory not found: {history_root}") + if not state.selected_history_run: + ui.label("No runs found.") + return + + run_id = state.selected_history_run + context = _load_run_context(run_id, output_dir=history_root) + sessions = _build_history_sessions(run_id, context.results, output_dir=history_root) + + session_ids = sorted(sessions.keys()) + if state.selected_history_session not in session_ids: + state.selected_history_session = session_ids[0] if session_ids else None + + turns: dict[str, list[dict]] = {} + if state.selected_history_session: + turns[state.selected_history_session] = _load_history_turns( + run_id, + state.selected_history_session, + output_dir=history_root, + ) + + with ui.card().classes("w-full card p-4"): + tabs, tab_by_name, active_tab = _init_tabs(state, "history", ["Overview", "Sessions", "Log"]) + overview_tab = tab_by_name["Overview"] + sessions_tab = tab_by_name["Sessions"] + log_tab = tab_by_name["Log"] + + with ui.tab_panels(tabs, value=active_tab).classes("w-full"): + with ui.tab_panel(overview_tab): + + def _open_history_session(row: dict) -> None: + _open_session_from_row(state, "history", row, history_panel.refresh) + + _render_overview_panel( + sessions, + run_active=False, + run_id=run_id, + results=context.results, + planned_sessions=context.planned_sessions, + total_workers=context.total_workers, + run_meta=context.run_meta, + on_open_session=_open_history_session, + ) + with ui.tab_panel(sessions_tab): + _render_dive_panel( + sessions, + turns, + run_id=run_id, + selected_session=state.selected_history_session, + on_session_change=on_history_session_change, + output_dir=history_root, + ) + with ui.tab_panel(log_tab): + _render_run_log(run_id, output_dir=history_root) + + +def _set_enabled(control: Any, enabled: bool) -> None: + if hasattr(control, "enabled"): + control.enabled = enabled + return + if enabled and hasattr(control, "enable"): + control.enable() + elif not enabled and hasattr(control, "disable"): + control.disable() + + +def _set_controls_enabled(controls: dict[str, Any], enabled: bool) -> None: + for data in controls.values(): + control = data[1] + _set_enabled(control, enabled) + + +def _set_visible(control: Any, visible: bool) -> None: + if hasattr(control, "visible"): + control.visible = visible + return + if visible and hasattr(control, "show"): + control.show() + elif not visible and hasattr(control, "hide"): + control.hide() + + +def build_run_tab(state: RunState) -> RunViews: + bench_label_to_key, agent_label_to_key = get_display_mappings() + bench_labels = list(bench_label_to_key.keys()) + agent_labels = list(agent_label_to_key.keys()) + + if state.bench_key is None and bench_labels: + state.bench_key = bench_label_to_key[bench_labels[0]] + if state.agent_key is None and agent_labels: + state.agent_key = agent_label_to_key[agent_labels[0]] + + with ui.column().classes("w-full items-center"): + with ui.column().classes("w-full max-w-6xl gap-4"): + with ui.element("div").classes("w-full split-grid"): + with ui.card().classes("w-full card p-4"): + with ui.row().classes("w-full items-center justify-between gap-3"): + ui.label("Agent") + agent_select = ( + ui.select( + agent_labels, + value=agent_labels[0] if agent_labels else None, + label="", + ) + .props("dense") + .style("min-width: 220px;") + ) + with ui.expansion("", value=False).classes("w-full"): + agent_form_panel(state) + with ui.card().classes("w-full card p-4"): + with ui.row().classes("w-full items-center justify-between gap-3"): + ui.label("Benchmark") + bench_select = ( + ui.select( + bench_labels, + value=bench_labels[0] if bench_labels else None, + label="", + ) + .props("dense") + .style("min-width: 220px;") + ) + with ui.expansion("", value=False).classes("w-full"): + bench_form_panel(state) + + with ui.card().classes("w-full card p-4"): + with ui.row().classes("w-full gap-4 items-end"): + num_tasks_input = ui.number( + label="Num Tasks", + value=state.num_tasks or 5, + min=0, + step=1, + ).props("dense") + max_workers_input = ui.number(label="Parallel Workers", value=state.max_workers, min=1).props( + "dense" + ) + start_button = ( + ui.button("Start Run") + .classes("ml-auto start-run-btn") + .style("background:#39ff14 !important; color:#0b0f10 !important;") + ) + + with ui.card().classes("w-full card p-4") as run_panel_box: + run_tabs, tab_by_name, active_tab = _init_tabs(state, "run", ["Overview", "Sessions", "Log"]) + overview_tab = tab_by_name["Overview"] + sessions_tab = tab_by_name["Sessions"] + log_tab = tab_by_name["Log"] + + with ui.tab_panels(run_tabs, value=active_tab).classes("w-full"): + with ui.tab_panel(overview_tab) as overview_panel_el: + overview_panel(state) + with ui.tab_panel(sessions_tab) as sessions_panel_el: + dive_panel(state) + with ui.tab_panel(log_tab) as log_panel_el: + run_log_panel(state) + + def on_bench_change(e) -> None: + label = e.value + state.bench_key = bench_label_to_key.get(label) + state.bench_controls = {} + bench_form_panel.refresh() + + def on_agent_change(e) -> None: + label = e.value + state.agent_key = agent_label_to_key.get(label) + agent_form_panel.refresh() + + def on_workers_change(e) -> None: + try: + state.max_workers = int(e.value) + except Exception: + state.max_workers = 1 + + def on_num_tasks_change(e) -> None: + try: + value = int(e.value) + except Exception: + state.num_tasks = None + return + state.num_tasks = value if value > 0 else None + + bench_select.on_value_change(on_bench_change) + agent_select.on_value_change(on_agent_change) + num_tasks_input.on_value_change(on_num_tasks_change) + max_workers_input.on_value_change(on_workers_change) + + show_sessions = state.run_active or bool(state.sessions) + _set_visible(run_panel_box, show_sessions) + _set_visible(overview_tab, show_sessions) + _set_visible(sessions_tab, show_sessions) + _set_visible(log_tab, show_sessions) + _set_visible(overview_panel_el, show_sessions) + _set_visible(sessions_panel_el, show_sessions) + _set_visible(log_panel_el, show_sessions) + + def start_run() -> None: + if state.run_active: + return + if state.bench_key is None or state.agent_key is None: + ui.notify("Please select a benchmark and agent.") + return + + num_tasks_value = None + try: + raw = num_tasks_input.value + if raw is not None: + parsed = int(raw) + if parsed > 0: + num_tasks_value = parsed + except Exception: + num_tasks_value = None + state.num_tasks = num_tasks_value + + benchmark = None + agent = None + try: + bench_cls = load_benchmark_class(state.bench_key) + agent_cls = load_agent_class(state.agent_key) + bench_values = _collect_values(state.bench_controls) + agent_values = _collect_values(state.agent_controls) + benchmark = bench_cls(**bench_values) + agent = agent_cls(**agent_values) + state.planned_sessions = num_tasks_value if num_tasks_value else None + except Exception as exc: + ui.notify(f"Config error: {exc}") + _LOG.info("config error: %s", exc) + return + finally: + if benchmark is not None: + try: + benchmark.close() + except Exception: + _LOG.info("benchmark preview close failed") + if agent is not None: + try: + agent.close() + except Exception: + _LOG.info("agent preview close failed") + + settings = get_settings() + output_dir = settings.output_dir + + state.events = [] + state.sessions = {} + state.turns = {} + state.refresh_needed = True + + ctx_mgr = run_scope(output_dir=output_dir) + ctx = ctx_mgr.__enter__() + dashboard_observer = DashboardEventsObserver() + + state.tracker = dashboard_observer + state.run_id = ctx.run_id + state.run_context_manager = ctx_mgr + state.run_active = True + os.environ["EXGENTIC_MAX_WORKERS"] = str(state.max_workers) + mode = "parallel" if state.max_workers > 1 else "sequential" + _LOG.info( + "starting run bench=%s agent=%s mode=%s workers=%s run_id=%s", + state.bench_key, + state.agent_key, + mode, + state.max_workers, + state.run_id, + ) + + def worker() -> None: + try: + _LOG.info("worker started") + config = RunConfig( + benchmark=state.bench_key, + agent=state.agent_key, + benchmark_kwargs=bench_values, + agent_kwargs=agent_values, + output_dir=output_dir, + max_workers=state.max_workers if state.max_workers > 1 else None, + run_id=state.run_id, + num_tasks=num_tasks_value, + ) + evaluate( + config, + observers=[dashboard_observer], + ) + _LOG.info("worker finished") + except Exception: + _LOG.exception("worker crashed") + + thread = threading.Thread(target=worker, daemon=True) + thread.start() + state.thread = thread + + start_button.on("click", lambda _: start_run()) + + return RunViews( + start_button=start_button, + bench_select=bench_select, + agent_select=agent_select, + num_tasks_input=num_tasks_input, + max_workers_input=max_workers_input, + bench_form=bench_form_panel, + agent_form=agent_form_panel, + overview_panel=overview_panel, + sessions_panel=dive_panel, + run_log_panel=run_log_panel, + leaderboard_panel=leaderboard_panel, + history_panel=history_panel, + run_panel_box=run_panel_box, + overview_tab=overview_tab, + sessions_tab=sessions_tab, + log_tab=log_tab, + overview_panel_el=overview_panel_el, + sessions_panel_el=sessions_panel_el, + log_panel_el=log_panel_el, + ) + + +def build_leaderboard_tab(state: RunState) -> None: + leaderboard_panel(state) + + +def build_history_tab(state: RunState) -> None: + history_panel(state) + + +def refresh_ui(state: RunState, views: RunViews) -> None: + changed = process_new_events(state) + + if state.run_active and state.thread and not state.thread.is_alive(): + state.run_active = False + if state.run_context_manager is not None: + try: + state.run_context_manager.__exit__(None, None, None) + except Exception: + pass + state.run_context_manager = None + state.refresh_needed = True + + _set_enabled(views.start_button, not state.run_active) + _set_enabled(views.bench_select, not state.run_active) + _set_enabled(views.agent_select, not state.run_active) + _set_enabled(views.num_tasks_input, not state.run_active) + _set_enabled(views.max_workers_input, not state.run_active) + _set_controls_enabled(state.bench_controls, not state.run_active) + _set_controls_enabled(state.agent_controls, not state.run_active) + + show_sessions = state.run_active or bool(state.sessions) + _set_visible(views.run_panel_box, show_sessions) + _set_visible(views.overview_tab, show_sessions) + _set_visible(views.sessions_tab, show_sessions) + _set_visible(views.log_tab, show_sessions) + _set_visible(views.overview_panel_el, show_sessions) + _set_visible(views.sessions_panel_el, show_sessions) + _set_visible(views.log_panel_el, show_sessions) + + if state.last_run_active != state.run_active: + views.bench_form.refresh() + views.agent_form.refresh() + state.last_run_active = state.run_active + + if changed: + views.overview_panel.refresh() + views.sessions_panel.refresh() + views.run_log_panel.refresh() + + if state.refresh_needed: + views.leaderboard_panel.refresh() + views.history_panel.refresh() + views.run_log_panel.refresh() + state.refresh_needed = False diff --git a/labs/AgentStream/exgentic/src/exgentic/interfaces/dashboard/views/state.py b/labs/AgentStream/exgentic/src/exgentic/interfaces/dashboard/views/state.py new file mode 100644 index 00000000..14683956 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/interfaces/dashboard/views/state.py @@ -0,0 +1,113 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import threading +from dataclasses import dataclass, field +from typing import Any, Optional + +from ....observers.handlers.dashboard_events import DashboardEventsObserver + + +@dataclass +class RunState: + events: list[dict] = field(default_factory=list) + sessions: dict[str, dict] = field(default_factory=dict) + turns: dict[str, list] = field(default_factory=dict) + bench_controls: dict[str, Any] = field(default_factory=dict) + agent_controls: dict[str, Any] = field(default_factory=dict) + tracker: DashboardEventsObserver | None = None + thread: threading.Thread | None = None + run_id: str | None = None + run_context_manager: Any = None + run_active: bool = False + refresh_needed: bool = True + last_run_active: bool = False + bench_key: str | None = None + agent_key: str | None = None + num_tasks: int | None = None + max_workers: int = 3 + selected_session: str | None = None + selected_history_run: str | None = None + selected_history_session: str | None = None + history_root: str = "" + selected_agents: list[str] = field(default_factory=list) + selected_models: list[str] = field(default_factory=list) + selected_benchmarks: list[str] = field(default_factory=list) + selected_subsets: list[str] = field(default_factory=list) + min_tasks: int = 0 + planned_sessions: int | None = None + active_tabs: dict[str, str] = field(default_factory=dict) + tabs_controls: dict[str, Any] = field(default_factory=dict) + tabs_by_scope: dict[str, dict[str, Any]] = field(default_factory=dict) + + +@dataclass +class RunContext: + results: Optional[dict] + config: Optional[dict] + run_meta: dict[str, Any] + planned_sessions: Optional[int] + total_workers: Optional[int] + + +@dataclass +class RunViews: + start_button: Any + bench_select: Any + agent_select: Any + num_tasks_input: Any + max_workers_input: Any + bench_form: Any + agent_form: Any + overview_panel: Any + sessions_panel: Any + run_log_panel: Any + leaderboard_panel: Any + history_panel: Any + run_panel_box: Any + overview_tab: Any + sessions_tab: Any + log_tab: Any + overview_panel_el: Any + sessions_panel_el: Any + log_panel_el: Any + + +SESSION_COLUMNS = [ + {"name": "session", "label": "Session", "field": "session"}, + {"name": "status", "label": "Status", "field": "status"}, + {"name": "steps", "label": "Steps", "field": "steps"}, + {"name": "score", "label": "Score", "field": "score"}, +] + +LEADERBOARD_COLUMNS = [ + {"name": "agent", "label": "Agent", "field": "Agent"}, + {"name": "model", "label": "Model", "field": "Model"}, + {"name": "benchmark", "label": "Benchmark", "field": "Benchmark"}, + {"name": "subset", "label": "Subset", "field": "Subset"}, + {"name": "tasks", "label": "Num Tasks", "field": "Num Tasks"}, + {"name": "score", "label": "Final Score", "field": "Final Score"}, + {"name": "run_cost", "label": "Total Run Cost", "field": "Total Run Cost"}, + {"name": "avg_agent_cost", "label": "Avg Agent Cost", "field": "Avg Agent Cost"}, +] + +TASK_RESULT_COLUMNS = [ + {"name": "session_id", "label": "Session", "field": "session_id"}, + {"name": "task_id", "label": "Task Id", "field": "task_id"}, + {"name": "success", "label": "Success", "field": "success"}, + {"name": "is_finished", "label": "Finished", "field": "is_finished"}, + {"name": "score", "label": "Score", "field": "score"}, + {"name": "steps", "label": "Steps", "field": "steps"}, + {"name": "agent_cost", "label": "Agent Cost", "field": "agent_cost"}, + {"name": "benchmark_cost", "label": "Benchmark Cost", "field": "benchmark_cost"}, + {"name": "execution_time", "label": "Exec Time", "field": "execution_time"}, +] + +ACTION_COLUMNS = [ + {"name": "name", "label": "Action", "field": "name"}, + {"name": "description", "label": "Description", "field": "description"}, + {"name": "is_message", "label": "Message", "field": "is_message"}, + {"name": "is_finish", "label": "Finish", "field": "is_finish"}, +] diff --git a/labs/AgentStream/exgentic/src/exgentic/interfaces/dashboard/views/status.py b/labs/AgentStream/exgentic/src/exgentic/interfaces/dashboard/views/status.py new file mode 100644 index 00000000..4b8ce072 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/interfaces/dashboard/views/status.py @@ -0,0 +1,87 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from typing import Any + +from nicegui import ui + +_STATUS_ORDER = [ + "success", + "unsuccessful", + "unfinished", + "agent error", + "benchmark error", + "cancelled", + "error", + "running", +] + +_STATUS_COLORS = { + "success": "#22c55e", + "unsuccessful": "#f59e0b", + "unfinished": "#facc15", + "agent error": "#ef4444", + "benchmark error": "#f97316", + "cancelled": "#94a3b8", + "error": "#dc2626", + "running": "#38bdf8", +} + + +def _status_from_outcome(success: Any, is_finished: Any, error_source: Any = None) -> str: + if success is True: + return "success" + if is_finished is True: + return "unsuccessful" + if is_finished is False: + return "unfinished" + if error_source == "agent": + return "agent error" + if error_source == "benchmark": + return "benchmark error" + if error_source == "cancelled": + return "cancelled" + return "error" + + +def _status_counts_from_sessions(sessions: dict) -> dict[str, int]: + counts: dict[str, int] = {} + for data in sessions.values(): + status = data.get("status") or "error" + counts[status] = counts.get(status, 0) + 1 + return counts + + +def _render_status_pie(status_counts: dict[str, int]) -> None: + if not status_counts: + ui.label("No session data available.") + return + data = [] + colors = [] + for status in _STATUS_ORDER: + count = status_counts.get(status, 0) + if count: + data.append({"value": count, "name": status}) + colors.append(_STATUS_COLORS.get(status, "#94a3b8")) + if not data: + ui.label("No session data available.") + return + ui.echart( + { + "tooltip": {"trigger": "item"}, + "legend": {"orient": "vertical", "left": "left"}, + "color": colors, + "series": [ + { + "name": "Sessions", + "type": "pie", + "radius": ["35%", "70%"], + "center": ["60%", "55%"], + "label": {"formatter": "{b}: {c} ({d}%)"}, + "data": data, + } + ], + } + ).classes("w-full").style("height: 260px;") diff --git a/labs/AgentStream/exgentic/src/exgentic/interfaces/lib/__init__.py b/labs/AgentStream/exgentic/src/exgentic/interfaces/lib/__init__.py new file mode 100644 index 00000000..7de8d437 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/interfaces/lib/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Public API helpers for interfaces.""" diff --git a/labs/AgentStream/exgentic/src/exgentic/interfaces/lib/api.py b/labs/AgentStream/exgentic/src/exgentic/interfaces/lib/api.py new file mode 100644 index 00000000..e1147a91 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/interfaces/lib/api.py @@ -0,0 +1,613 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import inspect +import json +from typing import Any + +from ...core.agent import Agent +from ...core.benchmark import Benchmark +from ...core.orchestrator.run import ( + core_aggregate, + core_evaluate, + core_execute, +) +from ...core.types import RunConfig, RunPlan, RunResults, RunStatus, SessionConfig +from ..registry import ( + apply_subset_kwargs, + get_agent_entries, + get_benchmark_entries, + get_benchmark_subsets, + load_agent, + load_benchmark, +) + + +def list_benchmarks() -> list[dict[str, Any]]: + from ...environment.instance import get_manager + + mgr = get_manager() + entries = get_benchmark_entries() + result = [] + for slug_name, entry in entries.items(): + name = f"benchmarks/{slug_name}" + info = mgr.get_info(name) + installed = info is not None + installed_at = None + if info: + envs = info["environments"] + timestamps = [e["installed_at"] for e in envs.values() if "installed_at" in e] + installed_at = min(timestamps) if timestamps else None + result.append( + { + "slug_name": slug_name, + "display_name": entry.display_name, + "installed": installed, + "installed_at": installed_at, + } + ) + return result + + +def list_agents() -> list[dict[str, Any]]: + from ...environment.instance import get_manager + + mgr = get_manager() + entries = get_agent_entries() + result = [] + for slug_name, entry in entries.items(): + name = f"agents/{slug_name}" + info = mgr.get_info(name) + installed = info is not None + installed_at = None + if info: + envs = info["environments"] + timestamps = [e["installed_at"] for e in envs.values() if "installed_at" in e] + installed_at = min(timestamps) if timestamps else None + result.append( + { + "slug_name": slug_name, + "display_name": entry.display_name, + "installed": installed, + "installed_at": installed_at, + } + ) + return result + + +def load_benchmark_class(benchmark: str) -> type[Benchmark]: + entries = get_benchmark_entries() + if benchmark not in entries: + raise ValueError(f"Unknown benchmark slug '{benchmark}'. Available: {', '.join(sorted(entries.keys()))}") + return load_benchmark(benchmark) + + +def load_agent_class(agent: str) -> type[Agent]: + entries = get_agent_entries() + if agent not in entries: + raise ValueError(f"Unknown agent slug '{agent}'. Available: {', '.join(sorted(entries.keys()))}") + return load_agent(agent) + + +def _run_config_from_session(session_config: SessionConfig) -> RunConfig: + return RunConfig( + benchmark=session_config.benchmark, + agent=session_config.agent, + subset=session_config.subset, + task_ids=[session_config.task_id], + output_dir=session_config.output_dir, + cache_dir=session_config.cache_dir, + run_id=session_config.run_id, + model=session_config.model, + benchmark_kwargs=session_config.benchmark_kwargs, + agent_kwargs=session_config.agent_kwargs, + overwrite_sessions=session_config.overwrite_sessions, + ) + + +def _normalize_run_config( + config: RunConfig | SessionConfig | None, + *, + benchmark: str | Benchmark | None, + agent: str | Agent | None, + subset: str | None, + task_ids: list[str] | None, + num_tasks: int | None, + output_dir: str, + cache_dir: str | None, + run_id: str | None, + model: str | None, + max_workers: int | None, + max_steps: int, + max_actions: int, + overwrite_sessions: bool, + benchmark_kwargs: dict[str, Any] | None, + agent_kwargs: dict[str, Any] | None, +) -> RunConfig: + if config is not None: + if ( + any( + value is not None + for value in ( + benchmark, + agent, + subset, + task_ids, + num_tasks, + cache_dir, + run_id, + model, + max_workers, + benchmark_kwargs, + agent_kwargs, + ) + ) + or overwrite_sessions + or output_dir != "./outputs" + or max_steps != 100 + or max_actions != 100 + ): + raise ValueError("Do not pass run parameters together with config.") + if isinstance(config, SessionConfig): + return _run_config_from_session(config) + return config + if benchmark is None or agent is None: + raise ValueError("benchmark and agent are required.") + + bench_slug: str + bench_kwargs: dict[str, Any] + if isinstance(benchmark, Benchmark): + if benchmark_kwargs is not None or subset is not None: + raise ValueError("Do not pass benchmark args with a benchmark instance.") + bench_slug = benchmark.slug_name + bench_kwargs = benchmark.model_dump() + subset = getattr(benchmark, "subset", None) + else: + bench_slug = benchmark + bench_kwargs = dict(benchmark_kwargs or {}) + + agent_slug: str + agent_cfg: dict[str, Any] + if isinstance(agent, Agent): + if agent_kwargs is not None or model is not None: + raise ValueError("Do not pass agent args with an agent instance.") + agent_slug = agent.slug_name + agent_cfg = agent.model_dump() + else: + agent_slug = agent + agent_cfg = dict(agent_kwargs or {}) + + return RunConfig( + benchmark=bench_slug, + agent=agent_slug, + subset=subset, + task_ids=task_ids, + num_tasks=num_tasks, + output_dir=output_dir, + cache_dir=cache_dir, + run_id=run_id, + model=model, + max_workers=max_workers, + max_steps=max_steps, + max_actions=max_actions, + overwrite_sessions=overwrite_sessions, + benchmark_kwargs=bench_kwargs, + agent_kwargs=agent_cfg, + ) + + +def evaluate( + config: RunConfig | SessionConfig | None = None, + *, + benchmark: str | Benchmark | None = None, + agent: str | Agent | None = None, + subset: str | None = None, + task_ids: list[str] | None = None, + num_tasks: int | None = None, + output_dir: str = "./outputs", + cache_dir: str | None = None, + run_id: str | None = None, + model: str | None = None, + max_workers: int | None = None, + max_steps: int = 100, + max_actions: int = 100, + overwrite_sessions: bool = False, + benchmark_kwargs: dict[str, Any] | None = None, + agent_kwargs: dict[str, Any] | None = None, + observers: list[Any] | None = None, + controllers: list[Any] | None = None, +) -> RunResults: + """Evaluate sessions and aggregate results. + + Accepts either a RunConfig/SessionConfig or benchmark/agent identifiers. + + Args: + config: RunConfig or SessionConfig. When provided, no other run args + may be passed. + benchmark: Benchmark slug or Benchmark instance. + agent: Agent slug or Agent instance. + subset: Benchmark subset name. + task_ids: Explicit task ids to run. + num_tasks: Number of tasks to run. + output_dir: Output root directory. + cache_dir: Cache directory. + run_id: Run id override. + model: Agent model override. + max_workers: Parallel workers. + max_steps: Max steps per session. + max_actions: Max actions per session. + overwrite_sessions: Overwrite existing session artifacts. + benchmark_kwargs: Benchmark kwargs (when benchmark is a slug). + agent_kwargs: Agent kwargs (when agent is a slug). + observers: Optional observers. + controllers: Optional controllers. + + Returns: + RunResults: Aggregated run results. + + Raises: + ValueError: If config is combined with other run args or if instance + args are mixed with kwargs. + """ + run_config = _normalize_run_config( + config, + benchmark=benchmark, + agent=agent, + subset=subset, + task_ids=task_ids, + num_tasks=num_tasks, + output_dir=output_dir, + cache_dir=cache_dir, + run_id=run_id, + model=model, + max_workers=max_workers, + max_steps=max_steps, + max_actions=max_actions, + overwrite_sessions=overwrite_sessions, + benchmark_kwargs=benchmark_kwargs, + agent_kwargs=agent_kwargs, + ) + return core_evaluate( + run_config=run_config, + observers=observers, + controllers=controllers, + ) + + +def execute( + config: RunConfig | SessionConfig | None = None, + *, + benchmark: str | Benchmark | None = None, + agent: str | Agent | None = None, + subset: str | None = None, + task_ids: list[str] | None = None, + num_tasks: int | None = None, + output_dir: str = "./outputs", + cache_dir: str | None = None, + run_id: str | None = None, + model: str | None = None, + max_workers: int | None = None, + max_steps: int = 100, + max_actions: int = 100, + overwrite_sessions: bool = False, + benchmark_kwargs: dict[str, Any] | None = None, + agent_kwargs: dict[str, Any] | None = None, + observers: list[Any] | None = None, + controllers: list[Any] | None = None, +) -> RunResults: + """Run sessions without aggregation. + + Accepts either a RunConfig/SessionConfig or benchmark/agent identifiers. + + Args: + config: RunConfig or SessionConfig. When provided, no other run args + may be passed. + benchmark: Benchmark slug or Benchmark instance. + agent: Agent slug or Agent instance. + subset: Benchmark subset name. + task_ids: Explicit task ids to run. + num_tasks: Number of tasks to run. + output_dir: Output root directory. + cache_dir: Cache directory. + run_id: Run id override. + model: Agent model override. + max_workers: Parallel workers. + max_steps: Max steps per session. + max_actions: Max actions per session. + overwrite_sessions: Overwrite existing session artifacts. + benchmark_kwargs: Benchmark kwargs (when benchmark is a slug). + agent_kwargs: Agent kwargs (when agent is a slug). + observers: Optional observers. + controllers: Optional controllers. + + Returns: + RunResults: Run results without aggregation. + + Raises: + ValueError: If config is combined with other run args or if instance + args are mixed with kwargs. + """ + run_config = _normalize_run_config( + config, + benchmark=benchmark, + agent=agent, + subset=subset, + task_ids=task_ids, + num_tasks=num_tasks, + output_dir=output_dir, + cache_dir=cache_dir, + run_id=run_id, + model=model, + max_workers=max_workers, + max_steps=max_steps, + max_actions=max_actions, + overwrite_sessions=overwrite_sessions, + benchmark_kwargs=benchmark_kwargs, + agent_kwargs=agent_kwargs, + ) + return core_execute( + run_config=run_config, + observers=observers, + controllers=controllers, + ) + + +def aggregate( + config: RunConfig | SessionConfig | None = None, + *, + benchmark: str | Benchmark | None = None, + agent: str | Agent | None = None, + subset: str | None = None, + task_ids: list[str] | None = None, + num_tasks: int | None = None, + output_dir: str = "./outputs", + cache_dir: str | None = None, + run_id: str | None = None, + model: str | None = None, + max_workers: int | None = None, + max_steps: int = 100, + max_actions: int = 100, + overwrite_sessions: bool = False, + benchmark_kwargs: dict[str, Any] | None = None, + agent_kwargs: dict[str, Any] | None = None, + observers: list[Any] | None = None, + controllers: list[Any] | None = None, +) -> RunResults: + """Aggregate results from completed sessions. + + Accepts either a RunConfig/SessionConfig or benchmark/agent identifiers. + + Args: + config: RunConfig or SessionConfig. When provided, no other run args + may be passed. + benchmark: Benchmark slug or Benchmark instance. + agent: Agent slug or Agent instance. + subset: Benchmark subset name. + task_ids: Explicit task ids to run. + num_tasks: Number of tasks to run. + output_dir: Output root directory. + cache_dir: Cache directory. + run_id: Run id override. + model: Agent model override. + max_workers: Parallel workers. + max_steps: Max steps per session. + max_actions: Max actions per session. + overwrite_sessions: Overwrite existing session artifacts. + benchmark_kwargs: Benchmark kwargs (when benchmark is a slug). + agent_kwargs: Agent kwargs (when agent is a slug). + observers: Optional observers. + controllers: Optional controllers. + + Returns: + RunResults: Aggregated run results. + + Raises: + ValueError: If config is combined with other run args or if instance + args are mixed with kwargs. + """ + run_config = _normalize_run_config( + config, + benchmark=benchmark, + agent=agent, + subset=subset, + task_ids=task_ids, + num_tasks=num_tasks, + output_dir=output_dir, + cache_dir=cache_dir, + run_id=run_id, + model=model, + max_workers=max_workers, + max_steps=max_steps, + max_actions=max_actions, + overwrite_sessions=overwrite_sessions, + benchmark_kwargs=benchmark_kwargs, + agent_kwargs=agent_kwargs, + ) + return core_aggregate( + run_config=run_config, + observers=observers, + controllers=controllers, + ) + + +def status( + config: RunConfig | SessionConfig | None = None, + *, + benchmark: str | Benchmark | None = None, + agent: str | Agent | None = None, + subset: str | None = None, + task_ids: list[str] | None = None, + num_tasks: int | None = None, + output_dir: str = "./outputs", + cache_dir: str | None = None, + run_id: str | None = None, + model: str | None = None, + max_workers: int | None = None, + max_steps: int = 100, + max_actions: int = 100, + overwrite_sessions: bool = False, + benchmark_kwargs: dict[str, Any] | None = None, + agent_kwargs: dict[str, Any] | None = None, +) -> RunStatus: + run_config = _normalize_run_config( + config, + benchmark=benchmark, + agent=agent, + subset=subset, + task_ids=task_ids, + num_tasks=num_tasks, + output_dir=output_dir, + cache_dir=cache_dir, + run_id=run_id, + model=model, + max_workers=max_workers, + max_steps=max_steps, + max_actions=max_actions, + overwrite_sessions=overwrite_sessions, + benchmark_kwargs=benchmark_kwargs, + agent_kwargs=agent_kwargs, + ) + return RunStatus.from_config(run_config) + + +def preview(config: RunConfig) -> RunPlan: + status = RunStatus.from_config(config) + return RunPlan.from_config_and_status( + config, + status, + ) + + +def results(config: RunConfig) -> RunResults: + from ...core.context import get_context + from ...utils.paths import RunPaths + + with config.get_context(): + results_path = RunPaths.from_context(get_context()).results + if not results_path.exists(): + raise ValueError(f"Run results not found at {results_path}.") + payload = json.loads(results_path.read_text(encoding="utf-8")) + return RunResults.model_validate(payload) + + +def get_benchmark_info(benchmark: str) -> dict[str, Any]: + entries = get_benchmark_entries() + entry = entries.get(benchmark) + if entry is None: + raise ValueError(f"Unknown benchmark slug '{benchmark}'. Available: {', '.join(sorted(entries.keys()))}") + bench_cls = load_benchmark_class(benchmark) + return { + "slug_name": entry.slug_name, + "display_name": entry.display_name, + "subsets": list(entry.subsets), + "subset_arg": entry.subset_arg, + "task_ids_arg": entry.task_ids_arg, + "task_id_type": entry.task_id_type, + "kwargs": _describe_init_args(bench_cls), + } + + +def get_agent_info(agent: str) -> dict[str, Any]: + entries = get_agent_entries() + entry = entries.get(agent) + if entry is None: + raise ValueError(f"Unknown agent slug '{agent}'. Available: {', '.join(sorted(entries.keys()))}") + agent_cls = load_agent_class(agent) + return { + "slug_name": entry.slug_name, + "display_name": entry.display_name, + "kwargs": _describe_init_args(agent_cls), + } + + +def list_subsets(benchmark: str) -> list[str]: + benchmark_entries = get_benchmark_entries() + if benchmark not in benchmark_entries: + raise ValueError( + f"Unknown benchmark slug '{benchmark}'. Available: {', '.join(sorted(benchmark_entries.keys()))}" + ) + return get_benchmark_subsets(benchmark) + + +def list_tasks( + *, + benchmark: str, + subset: str | None = None, + benchmark_kwargs: dict[str, Any] | None = None, +) -> list[str]: + benchmark_entries = get_benchmark_entries() + if benchmark not in benchmark_entries: + raise ValueError( + f"Unknown benchmark slug '{benchmark}'. Available: {', '.join(sorted(benchmark_entries.keys()))}" + ) + bench_kwargs = dict(benchmark_kwargs or {}) + if subset is not None: + bench_kwargs = apply_subset_kwargs(benchmark, subset, bench_kwargs) + bench_cls = load_benchmark_class(benchmark) + benchmark_obj: Benchmark = bench_cls(**bench_kwargs) + evaluator = benchmark_obj.get_evaluator() + try: + try: + return evaluator.list_tasks() + except NotImplementedError as exc: + raise ValueError(str(exc)) from exc + finally: + try: + evaluator.close() + except Exception: + pass + benchmark_obj.close() + + +def needs_setup(name: str, kind: str) -> bool: + """Return True if a benchmark/agent has a setup.sh or requirements.txt.""" + from ...environment.helpers import find_package_file + + entries = get_benchmark_entries() if kind == "benchmark" else get_agent_entries() + entry = entries.get(name) + if entry is None: + return False + return ( + find_package_file(entry.module, "setup.sh") is not None + or find_package_file(entry.module, "requirements.txt") is not None + ) + + +def _describe_init_args(cls: type) -> list[str]: + model_fields = getattr(cls, "model_fields", None) + if model_fields: + names = set(model_fields.keys()) + for field in model_fields.values(): + alias = getattr(field, "alias", None) + if alias and alias not in names: + names.add(alias) + return sorted(names) + try: + sig = inspect.signature(cls.__init__) + except (TypeError, ValueError): + return [] + args = [] + for name, param in sig.parameters.items(): + if name == "self": + continue + if param.kind == param.VAR_KEYWORD: + args.append("**kwargs") + continue + args.append(name) + return args + + +__all__ = [ + "aggregate", + "evaluate", + "execute", + "list_agents", + "list_benchmarks", + "list_subsets", + "list_tasks", + "preview", + "results", + "status", +] diff --git a/labs/AgentStream/exgentic/src/exgentic/interfaces/registry.py b/labs/AgentStream/exgentic/src/exgentic/interfaces/registry.py new file mode 100644 index 00000000..089f3c31 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/interfaces/registry.py @@ -0,0 +1,352 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import importlib +import importlib.util +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from pydantic import BaseModel + +if TYPE_CHECKING: + from ..core.agent import Agent + from ..core.benchmark import Benchmark + + +@dataclass(frozen=True) +class RegistryEntry: + slug_name: str + display_name: str + module: str + attr: str + kind: str + subsets: tuple[str, ...] = () + subset_arg: str | None = None + task_ids_arg: str | None = None + task_id_type: str | None = None + + def is_available(self) -> bool: + return importlib.util.find_spec(self.module) is not None + + def load(self) -> type: + try: + module = importlib.import_module(self.module) + except Exception as exc: + raise ImportError(f"Failed to import {self.kind} '{self.slug_name}' from {self.module}: {exc}") from exc + try: + return getattr(module, self.attr) + except AttributeError as exc: + raise ImportError(f"Missing {self.kind} class '{self.attr}' in {self.module}") from exc + + +BENCHMARKS: dict[str, RegistryEntry] = { + "bfcl": RegistryEntry( + slug_name="bfcl", + display_name="BFCL", + module="exgentic.benchmarks.bfcl.bfcl_benchmark", + attr="BFCLBenchmark", + kind="benchmark", + subsets=( + "simple_python", + "simple_java", + "simple_javascript", + "multiple", + "parallel", + "parallel_multiple", + "irrelevance", + "live_simple", + "live_multiple", + "live_parallel", + "live_parallel_multiple", + "live_irrelevance", + "live_relevance", + "multi_turn_base", + "multi_turn_long_context", + "multi_turn_miss_func", + "multi_turn_miss_param", + ), + subset_arg="subset", + task_id_type="str", + ), + "tau2": RegistryEntry( + slug_name="tau2", + display_name="Tau Bench 2", + module="exgentic.benchmarks.tau2.tau2_benchmark", + attr="TAU2Benchmark", + kind="benchmark", + subsets=("mock", "retail", "airline", "telecom"), + subset_arg="subset", + task_id_type="int", + ), + "appworld": RegistryEntry( + slug_name="appworld", + display_name="AppWorld", + module="exgentic.benchmarks.appworld.appworld_benchmark", + attr="AppWorldBenchmark", + kind="benchmark", + subsets=("train", "dev", "test_normal", "test_challenge"), + subset_arg="subset", + task_id_type="str", + ), + "gsm8k": RegistryEntry( + slug_name="gsm8k", + display_name="GSM8k", + module="exgentic.benchmarks.gsm8k.gsm8k_benchmark", + attr="GSM8kBenchmark", + kind="benchmark", + subsets=("main",), + subset_arg="subset", + task_id_type="int", + ), + "hle": RegistryEntry( + slug_name="hle", + display_name="HLE", + module="exgentic.benchmarks.hle.hle_benchmark", + attr="HLEBenchmark", + kind="benchmark", + subsets=("test",), + subset_arg="subset", + task_id_type="int", + ), + "hotpotqa": RegistryEntry( + slug_name="hotpotqa", + display_name="HotpotQA", + module="exgentic.benchmarks.hotpotqa.hotpotqa_benchmark", + attr="HotpotQABenchmark", + kind="benchmark", + subsets=("distractor",), + subset_arg="subset", + task_id_type="int", + ), + "browsecompplus": RegistryEntry( + slug_name="browsecompplus", + display_name="BrowseCompPlus", + module="exgentic.benchmarks.browsecompplus.browsecomp_benchmark", + attr="BrowseCompPlusBenchmark", + kind="benchmark", + subsets=("main",), + subset_arg="subset", + task_id_type="int", + ), + "swebench": RegistryEntry( + slug_name="swebench", + display_name="SWE-bench", + module="exgentic.benchmarks.swebench.swebench_benchmark", + attr="SWEBenchBenchmark", + kind="benchmark", + subsets=(), + subset_arg="subset", + task_id_type="str", + ), +} + +AGENTS: dict[str, RegistryEntry] = { + "tool_calling": RegistryEntry( + slug_name="tool_calling", + display_name="LiteLLM Tool Calling", + module="exgentic.agents.litellm_tool_calling.litellm_tool_calling_agent", + attr="LiteLLMToolCallingAgent", + kind="agent", + ), + "smolagents_tool": RegistryEntry( + slug_name="smolagents_tool", + display_name="SmolAgents Tool Calling", + module="exgentic.agents.smolagents.tool_calling_agent", + attr="SmolagentToolCallingAgent", + kind="agent", + ), + "smolagents_code": RegistryEntry( + slug_name="smolagents_code", + display_name="SmolAgents Code", + module="exgentic.agents.smolagents.code_agent", + attr="SmolagentCodeAgent", + kind="agent", + ), + "openai_solo": RegistryEntry( + slug_name="openai_solo", + display_name="OpenAI Solo", + module="exgentic.agents.openai.openai_mcp_agent", + attr="OpenAIMCPAgent", + kind="agent", + ), + "claude_code": RegistryEntry( + slug_name="claude_code", + display_name="Claude Code CLI", + module="exgentic.agents.cli.claude.agent", + attr="ClaudeCodeAgent", + kind="agent", + ), + "codex_cli": RegistryEntry( + slug_name="codex_cli", + display_name="Codex CLI", + module="exgentic.agents.cli.codex.agent", + attr="CodexAgent", + kind="agent", + ), + "gemini_cli": RegistryEntry( + slug_name="gemini_cli", + display_name="Gemini CLI", + module="exgentic.agents.cli.gemini.agent", + attr="GeminiAgent", + kind="agent", + ), + "ace": RegistryEntry( + slug_name="ace", + display_name="ACE Agent", + module="exgentic.agents.ace.ace_agent", + attr="ACEAgent", + kind="agent", + ), + "a_mem": RegistryEntry( + slug_name="a_mem", + display_name="A-Mem Agent", + module="exgentic.agents.a_mem.a_mem_agent", + attr="AMemAgent", + kind="agent", + ), + "reasoning_bank": RegistryEntry( + slug_name="reasoning_bank", + display_name="ReasoningBank Agent", + module="exgentic.agents.reasoning_bank.rb_agent", + attr="ReasoningBankAgent", + kind="agent", + ), + "autoskill": RegistryEntry( + slug_name="autoskill", + display_name="AutoSkill Agent", + module="exgentic.agents.autoskill.autoskill_agent", + attr="AutoSkillAgent", + kind="agent", + ), + "harness": RegistryEntry( + slug_name="harness", + display_name="Harness Agent", + module="exgentic.agents.harness.harness_agent", + attr="HarnessAgent", + kind="agent", + ), +} + + +def get_benchmark_entries() -> dict[str, RegistryEntry]: + return dict(BENCHMARKS) + + +def get_agent_entries() -> dict[str, RegistryEntry]: + return dict(AGENTS) + + +def get_benchmark_subsets(slug_name: str) -> list[str]: + entry = BENCHMARKS.get(slug_name) + if entry is None: + raise KeyError(f"Unknown benchmark slug '{slug_name}'") + return list(entry.subsets) + + +def get_benchmark_subset_arg(slug_name: str) -> str | None: + entry = BENCHMARKS.get(slug_name) + if entry is None: + raise KeyError(f"Unknown benchmark slug '{slug_name}'") + return entry.subset_arg + + +def apply_subset_kwargs(slug_name: str, subset: str | None, kwargs: dict[str, Any]) -> dict[str, Any]: + if subset is None: + return kwargs + subsets = get_benchmark_subsets(slug_name) + if subsets and subset not in subsets: + raise ValueError(f"Unknown subset '{subset}' for '{slug_name}'. Available: {', '.join(subsets)}") + subset_arg = get_benchmark_subset_arg(slug_name) + if subset_arg: + if subset_arg in kwargs and kwargs[subset_arg] != subset: + raise ValueError(f"Conflicting subset selection: {subset_arg}={kwargs[subset_arg]} but subset={subset}") + merged = dict(kwargs) + merged[subset_arg] = subset + return merged + if subsets and subset != subsets[0]: + raise ValueError( + f"Benchmark '{slug_name}' does not support subset selection; default subset is '{subsets[0]}'." + ) + return kwargs + + +def apply_task_kwargs(slug_name: str, tasks: list[str] | None, kwargs: dict[str, Any]) -> dict[str, Any]: + if not tasks: + return kwargs + entry = BENCHMARKS.get(slug_name) + if entry is None: + raise KeyError(f"Unknown benchmark slug '{slug_name}'") + if not entry.task_ids_arg: + raise ValueError(f"Benchmark '{slug_name}' does not support task filtering.") + if entry.task_id_type == "int": + try: + coerced = [int(v) for v in tasks] + except Exception as exc: + raise ValueError(f"Invalid task for '{slug_name}': {tasks}. Expected integers.") from exc + else: + coerced = [str(v) for v in tasks] + if entry.task_ids_arg in kwargs and kwargs[entry.task_ids_arg] != coerced: + raise ValueError( + f"Conflicting task selection: {entry.task_ids_arg}={kwargs[entry.task_ids_arg]} but tasks={coerced}" + ) + merged = dict(kwargs) + merged[entry.task_ids_arg] = coerced + return merged + + +def load_benchmark(slug_name: str) -> type[Benchmark]: + entry = BENCHMARKS.get(slug_name) + if entry is None: + raise KeyError(f"Unknown benchmark slug '{slug_name}'") + cls = entry.load() + _validate_entry(entry, cls) + return cls # type: ignore[return-value] + + +def load_agent(slug_name: str) -> type[Agent]: + entry = AGENTS.get(slug_name) + if entry is None: + raise KeyError(f"Unknown agent slug '{slug_name}'") + cls = entry.load() + _validate_entry(entry, cls) + return cls # type: ignore[return-value] + + +def _validate_entry(entry: RegistryEntry, cls: type) -> None: + try: + slug = cls.slug_name + except AttributeError as exc: + raise ValueError(f"{entry.kind} class '{entry.attr}' is missing slug_name") from exc + if str(slug) != entry.slug_name: + raise ValueError( + f"{entry.kind} slug mismatch: registry '{entry.slug_name}' " + f"!= class '{slug}' for {entry.module}.{entry.attr}" + ) + try: + display = cls.display_name + except AttributeError as exc: + raise ValueError(f"{entry.kind} class '{entry.attr}' is missing display_name") from exc + if str(display) != entry.display_name: + raise ValueError( + f"{entry.kind} display_name mismatch: registry '{entry.display_name}' " + f"!= class '{display}' for {entry.module}.{entry.attr}" + ) + if not issubclass(cls, BaseModel): + raise TypeError(f"{entry.kind} class '{entry.attr}' must be a Pydantic BaseModel.") + + +__all__ = [ + "AGENTS", + "BENCHMARKS", + "RegistryEntry", + "apply_subset_kwargs", + "apply_task_kwargs", + "get_agent_entries", + "get_benchmark_entries", + "get_benchmark_subset_arg", + "get_benchmark_subsets", + "load_agent", + "load_benchmark", +] diff --git a/labs/AgentStream/exgentic/src/exgentic/observers/__init__.py b/labs/AgentStream/exgentic/src/exgentic/observers/__init__.py new file mode 100644 index 00000000..367fec41 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/observers/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. diff --git a/labs/AgentStream/exgentic/src/exgentic/observers/handlers/__init__.py b/labs/AgentStream/exgentic/src/exgentic/observers/handlers/__init__.py new file mode 100644 index 00000000..ff5bac8c --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/observers/handlers/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Observer handlers for filesystem, console, and dashboard outputs.""" diff --git a/labs/AgentStream/exgentic/src/exgentic/observers/handlers/configs.py b/labs/AgentStream/exgentic/src/exgentic/observers/handlers/configs.py new file mode 100644 index 00000000..d35a212e --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/observers/handlers/configs.py @@ -0,0 +1,48 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import json +import threading +from typing import Any, Optional + +from ...core.orchestrator.observer import Observer + + +class ConfigsObserver(Observer): + def __init__(self, run_id: str | None = None) -> None: + super().__init__(run_id) + self._lock = threading.Lock() + self._run_config: Optional[Any] = None + + def on_run_start(self, run_config) -> None: + with self._lock: + self._run_config = run_config + self._write_config(run_config) + + def on_run_success(self, results, run_config) -> None: + with self._lock: + self._run_config = run_config + self._write_config(run_config) + + def on_run_error(self, error) -> None: + with self._lock: + run_config = self._run_config + if run_config is None: + return + self._write_config(run_config) + + def _write_config(self, run_config) -> None: + rp = self.paths + config_path = rp.config + try: + config_path.parent.mkdir(parents=True, exist_ok=True) + with open(config_path, "w", encoding="utf-8") as f: + json.dump( + run_config.model_dump(mode="json"), + f, + ensure_ascii=False, + indent=2, + ) + except OSError: + # Read-only runs should still be able to aggregate without persisting. + return diff --git a/labs/AgentStream/exgentic/src/exgentic/observers/handlers/dashboard_events.py b/labs/AgentStream/exgentic/src/exgentic/observers/handlers/dashboard_events.py new file mode 100644 index 00000000..c4ea8e0e --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/observers/handlers/dashboard_events.py @@ -0,0 +1,360 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import json +import threading +import time +from collections import deque +from queue import Queue +from typing import Any + +from pydantic import BaseModel + +from ...core.agent import Agent +from ...core.context import get_context +from ...core.orchestrator.observer import Observer +from ...core.orchestrator.termination import ( + AgentError, + BenchmarkError, + RunCancelError, + SessionCancelError, +) +from ...core.types import BenchmarkResults, SessionScore +from ...utils.paths import RunPaths + + +class DashboardEventsObserver(Observer): + """Emit dashboard-friendly run events to a queue.""" + + def __init__(self) -> None: + self.events: Queue[dict[str, Any]] = Queue(maxsize=10000) + self._thread_session: dict[int, str] = {} + self._session_steps: dict[str, int] = {} + self._session_started_at: dict[str, float] = {} + self._session_agents: dict[str, Agent] = {} + self._events_lock = threading.Lock() + self._pending_events: deque = deque() + self._last_batch_time = time.time() + self._batch_interval = 0.02 + self._pending_run_meta = True + + def _emit(self, etype: str, **payload: Any) -> None: + evt = {"type": etype, "ts": time.time(), **payload} + + current_time = time.time() + + if etype in ("session_started", "session_finished", "run_meta", "saved"): + try: + self.events.put_nowait(evt) + except Exception: + try: + self.events.get_nowait() + self.events.put_nowait(evt) + except Exception: + pass + return + + with self._events_lock: + if len(self._pending_events) >= 100: + self._pending_events.popleft() + self._pending_events.append(evt) + + if current_time - self._last_batch_time >= self._batch_interval or len(self._pending_events) >= 10: + self._flush_batch() + self._last_batch_time = current_time + + def _flush_batch(self) -> None: + while self._pending_events: + try: + evt = self._pending_events.popleft() + self.events.put_nowait(evt) + except Exception: + try: + self.events.get_nowait() + self.events.put_nowait(evt) + except Exception: + break + + def on_run_start(self, run_config) -> None: + self._emit_run_meta() + + def on_session_start(self, session, agent: Agent, observation) -> None: + sid = session.session_id + tid = threading.get_ident() + with self._events_lock: + if tid is not None: + self._thread_session[int(tid)] = sid + self._session_steps[sid] = 0 + self._session_started_at[sid] = time.time() + self._session_agents[sid] = agent + self._emit("session_started", session_id=sid) + + def on_react_success(self, session, action) -> None: + step_n = None + sid = None + tid = threading.get_ident() + if tid is not None: + with self._events_lock: + sid = self._thread_session.get(int(tid)) + if sid: + step_n = self._session_steps.get(sid, 0) + 1 + self._session_steps[sid] = step_n + + def _action_summary(a: Any) -> str: + try: + from ...core.types import ParallelAction, SingleAction + + if isinstance(a, SingleAction): + return f"{a.name}"[:100] + if isinstance(a, ParallelAction): + return f"parallel[{len(a.actions)} actions]" + except Exception: + pass + return str(a)[:100] + + def _action_obj(a: Any): + try: + from ...core.types import ParallelAction, SingleAction + + if isinstance(a, SingleAction): + args = a.arguments.model_dump() + if isinstance(args, dict) and len(str(args)) > 500: + args = {k: (v if len(str(v)) < 50 else f"{str(v)[:50]}...") for k, v in list(args.items())[:5]} + return {"type": "single", "name": a.name, "arguments": args} + if isinstance(a, ParallelAction): + items = [] + for i, x in enumerate(a.actions[:5]): + try: + args = x.arguments.model_dump() + if isinstance(args, dict) and len(str(args)) > 200: + args = { + k: (v if len(str(v)) < 30 else f"{str(v)[:30]}...") + for k, v in list(args.items())[:3] + } + items.append({"name": x.name, "arguments": args}) + except Exception: + items.append(str(x)[:100]) + if i >= 4: + break + return {"type": "parallel", "actions": items} + except Exception: + return None + return None + + if sid: + agent_cost, benchmark_cost = self._get_cost_snapshot(sid, session) + execution_time = self._get_execution_time(sid) + self._emit( + "step", + event="action", + session_id=sid, + n=step_n, + action=_action_summary(action), + action_obj=_action_obj(action), + execution_time=execution_time, + agent_cost=agent_cost, + benchmark_cost=benchmark_cost, + ) + + def on_step_success(self, session, observation) -> None: + sid = None + tid = threading.get_ident() + if tid is not None: + with self._events_lock: + sid = self._thread_session.get(int(tid)) + + def _safe_json(obj: Any) -> str: + try: + if isinstance(obj, BaseModel): + data = obj.model_dump() + if isinstance(data, dict) and len(str(data)) > 1000: + truncated = { + k: (v if len(str(v)) < 100 else f"{str(v)[:100]}...") for k, v in list(data.items())[:10] + } + return json.dumps(truncated, ensure_ascii=False) + return json.dumps(data, ensure_ascii=False) + obj_str = str(obj) + if len(obj_str) > 1000: + obj_str = obj_str[:1000] + "..." + return json.dumps(obj_str, default=str, ensure_ascii=False) + except Exception: + return str(obj)[:500] + + def _obs_obj(o: Any): + try: + if isinstance(o, BaseModel): + data = o.model_dump() + if isinstance(data, dict) and len(str(data)) > 1000: + truncated = { + k: (v if len(str(v)) < 100 else f"{str(v)[:100]}...") for k, v in list(data.items())[:10] + } + return truncated + return data + safe_json = _safe_json(o) + return json.loads(safe_json) + except Exception: + return None + + if sid: + step_n = self._session_steps.get(sid, 0) + agent_cost, benchmark_cost = self._get_cost_snapshot(sid, session) + execution_time = self._get_execution_time(sid) + self._emit( + "observation", + event="observation", + session_id=sid, + step=step_n, + observation=_obs_obj(observation), + initial=False, + execution_time=execution_time, + agent_cost=agent_cost, + benchmark_cost=benchmark_cost, + ) + + def on_session_success(self, session, score: SessionScore, agent: Agent) -> None: + sid = session.session_id + self._flush_pending() + self._cleanup_thread_session() + success = bool(score.success) + value = score.score + details = score.model_dump() + with self._events_lock: + steps = self._session_steps.pop(sid, 0) + started_at = self._session_started_at.pop(sid, None) + self._session_agents.pop(sid, None) + execution_time = time.time() - started_at if started_at is not None else None + agent_cost = 0.0 + benchmark_cost = 0.0 + if agent is not None: + try: + report = agent.get_cost() + agent_cost = float(report.total_cost) + except Exception: + agent_cost = 0.0 + try: + report = session.get_cost() + benchmark_cost = float(report.total_cost) + except Exception: + benchmark_cost = 0.0 + self._emit( + "session_finished", + session_id=sid, + success=success, + score=value, + details=details, + steps=steps, + execution_time=execution_time, + agent_cost=agent_cost, + benchmark_cost=benchmark_cost, + is_finished=score.is_finished, + ) + + def on_session_error(self, session, error) -> None: + sid = session.session_id + self._flush_pending() + self._cleanup_thread_session() + error_source = None + if isinstance(error, AgentError): + error_source = "agent" + elif isinstance(error, BenchmarkError): + error_source = "benchmark" + elif isinstance(error, (SessionCancelError, RunCancelError, KeyboardInterrupt)): + error_source = "cancelled" + root_error = error.error if isinstance(error, (AgentError, BenchmarkError)) else None + error_message = str(root_error) if root_error else str(error) + details = {"error": error_message} + if error_source is not None: + details["error_source"] = error_source + with self._events_lock: + steps = self._session_steps.pop(sid, 0) + started_at = self._session_started_at.pop(sid, None) + self._session_agents.pop(sid, None) + execution_time = time.time() - started_at if started_at is not None else None + agent_cost = 0.0 + benchmark_cost = 0.0 + try: + report = session.get_cost() + benchmark_cost = float(report.total_cost) + except Exception: + benchmark_cost = 0.0 + self._emit( + "session_finished", + session_id=sid, + success=False, + score=None, + details=details, + steps=steps, + execution_time=execution_time, + agent_cost=agent_cost, + benchmark_cost=benchmark_cost, + is_finished=None, + error_source=error_source, + ) + + def _get_execution_time(self, session_id: str) -> float | None: + with self._events_lock: + started_at = self._session_started_at.get(session_id) + if started_at is None: + return None + return time.time() - started_at + + def _get_cost_snapshot(self, session_id: str, session) -> tuple[float, float]: + with self._events_lock: + agent = self._session_agents.get(session_id) + agent_cost = 0.0 + benchmark_cost = 0.0 + if agent is not None: + try: + report = agent.get_cost() + agent_cost = float(report.total_cost) + except Exception: + agent_cost = 0.0 + try: + report = session.get_cost() + benchmark_cost = float(report.total_cost) + except Exception: + benchmark_cost = 0.0 + return agent_cost, benchmark_cost + + def on_run_success(self, results: BenchmarkResults, run_config) -> None: + payload = results.model_dump() + self._emit("benchmark_recorded", results=payload) + self._emit_saved_from_context() + + def on_run_error(self, error) -> None: + self._emit_saved_from_context() + + def emit_saved(self, path: str) -> None: + self._flush_pending() + self._emit("saved", path=path) + + def _flush_pending(self) -> None: + with self._events_lock: + if self._pending_events: + self._flush_batch() + + def _cleanup_thread_session(self) -> None: + tid = threading.get_ident() + if tid is not None: + with self._events_lock: + self._thread_session.pop(int(tid), None) + + def _emit_run_meta(self) -> None: + if not self._pending_run_meta: + return + try: + run_id = get_context().run_id + except RuntimeError: + return + self._emit("run_meta", run_id=run_id) + self._pending_run_meta = False + + def _emit_saved_from_context(self) -> None: + try: + ctx = get_context() + except RuntimeError: + return + results_path = RunPaths.from_context(ctx).results + self.emit_saved(str(results_path)) diff --git a/labs/AgentStream/exgentic/src/exgentic/observers/handlers/file_logger.py b/labs/AgentStream/exgentic/src/exgentic/observers/handlers/file_logger.py new file mode 100644 index 00000000..e74ecfff --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/observers/handlers/file_logger.py @@ -0,0 +1,337 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import threading +import time +from typing import Dict + +from ...core.orchestrator.observer import Observer +from ...core.orchestrator.termination import ( + AgentError, + BenchmarkError, + InvalidActionError, + InvalidObservationError, + RunCancelError, + SessionCancelError, +) +from ...core.types import Action, Observation, SessionResults, SessionScore +from ...interfaces.registry import get_agent_entries, get_benchmark_entries +from ..logging import get_logger +from .session_ledger import SessionLedger + + +class FileLoggerObserver(Observer): + def __init__( + self, + run_id: str | None = None, + *, + console: bool = False, + logger=None, + ) -> None: + super().__init__(run_id) + self._logger = logger + self._console = console + self._lock = threading.Lock() + self._ledger = SessionLedger() + self._session_reasons: Dict[str, str] = {} + + def _ensure_logger(self) -> None: + if self._logger is not None: + return + rp = self.paths + log_path = rp.tracker + self._logger = get_logger( + f"tracker.{self._run_id}", + str(log_path), + console=self._console, + propagate=False, + ) + + def on_run_start(self, run_config) -> None: + self._ensure_logger() + run_id = self._run_id + bench_entry = get_benchmark_entries().get(run_config.benchmark) + agent_entry = get_agent_entries().get(run_config.agent) + bench_name = bench_entry.display_name if bench_entry is not None else run_config.benchmark + agent_name = agent_entry.display_name if agent_entry is not None else run_config.agent + model_value = run_config.model or (run_config.agent_kwargs or {}).get("model") + model_names = [str(model_value)] if model_value else None + models_text = ", ".join(model_names) if model_names else "" + if run_id is not None: + self._logger.info("==== Exgentic Run %s ====", run_id) + if models_text: + self._logger.info( + "Agent: %s (%s) | Benchmark: %s", + agent_name, + models_text, + bench_name, + ) + else: + self._logger.info( + "Agent: %s | Benchmark: %s", + agent_name, + bench_name, + ) + + def on_run_success(self, results, run_config) -> None: + self._log_save() + + def on_run_error(self, error) -> None: + self._log_error("run", None, error) + self._log_save() + + def on_session_start(self, session, agent, observation) -> None: + self._ensure_logger() + session_id = session.session_id + session_number = self._ledger.register(session_id) + self._logger.info("▶️ Starting Session %s (logs: %s)", session_number, session.paths.root) + if isinstance(observation, Observation): + self._logger.info("⏺️ Recorded Start Session %s", session_number) + + def on_react_success(self, session, action) -> None: + self._ensure_logger() + if action is None: + self._set_reason(session, "ended by agent (agent returned None action)") + return + if not isinstance(action, Action): + self._set_reason( + session, + f"terminated by illegal action returned from agent: {action}", + ) + self._log_error("react", session, InvalidActionError(action)) + return + step_n, session_number = self._step(session) + self._logger.info("⏩ Recorded Step %s Session %s", step_n, session_number) + + def on_step_success(self, session, observation) -> None: + self._ensure_logger() + if observation is not None and not isinstance(observation, Observation): + self._set_reason( + session, + "terminated by illegal observation returned from session: " f"{observation}", + ) + self._log_error("step", session, InvalidObservationError(observation)) + return + if observation is None: + self._set_reason(session, "ended by benchmark") + + def on_react_error(self, session, error) -> None: + self._ensure_logger() + if isinstance(error, InvalidActionError): + self._set_reason( + session, + f"terminated by illegal action returned from agent: {error.action}", + ) + else: + self._set_reason(session, "terminated by agent exception") + + def on_step_error(self, session, error) -> None: + self._ensure_logger() + if isinstance(error, InvalidObservationError): + self._set_reason( + session, + "terminated by illegal observation returned from session: " f"{error.observation}", + ) + else: + self._set_reason(session, "terminated by session exception") + + def on_session_error(self, session, error) -> None: + self._ensure_logger() + error_source = None + if isinstance(error, AgentError): + error_source = "agent" + elif isinstance(error, BenchmarkError): + error_source = "benchmark" + if isinstance(error, (SessionCancelError, RunCancelError, KeyboardInterrupt)): + self._set_reason(session, "cancelled by user", overwrite=True) + error_source = "cancelled" + else: + if error_source == "agent": + reason = "terminated by agent exception" + elif error_source == "benchmark": + reason = "terminated by benchmark exception" + else: + reason = "terminated by unexpected exception (see console)" + self._set_reason(session, reason, overwrite=True) + session_id = session.session_id if session else None + detail = self._format_error_detail(error) + if error_source == "agent": + self._logger.error( + "Agent error in session %s: %s", + session_id or "-", + detail, + ) + elif error_source == "benchmark": + self._logger.error( + "Benchmark error in session %s: %s", + session_id or "-", + detail, + ) + else: + self._logger.error( + "Session error in session %s: %s", + session_id or "-", + detail, + ) + score = SessionScore(score=0, success=False, is_finished=None) + self._log_session(session, score) + + def on_session_success(self, session, score, agent) -> None: + self._ensure_logger() + self._log_session(session, score) + + def on_session_scoring(self, session) -> None: + self._ensure_logger() + session_id = session.session_id + session_number = self._ledger.get_number(session_id) + self._logger.info("⏳ Scoring Session %s (logs: %s)", session_number, session.paths.root) + + def _log_save(self) -> None: + self._ensure_logger() + rp = self.paths + self._logger.info("💾 Saving results to %s", rp.root) + + def _step(self, session) -> tuple[int, int]: + session_id = session.session_id + step_n = self._ledger.increment_steps(session_id) + session_number = self._ledger.get_number(session_id) + return step_n, session_number + + def _log_session(self, session, score: SessionScore) -> None: + session_id = session.session_id + session_number = self._ledger.get_number(session_id) + stats = self._ledger.pop_state(session_id) + execution_time = time.time() - stats.started_at if stats is not None else 0.0 + steps = stats.steps if stats is not None else 0 + reason = self._pop_reason(session) + self._logger.info("⏹️ Session %s %s.", session_number, reason) + + success = bool(score.success) + value = score.score + is_finished = score.is_finished + score_text = f"{value}" + success_emoji = self._success_emoji(success, value, is_finished) + status = self._status_label(success, is_finished, reason) + task_id = session.task_id + task_id_str = f" | task_id: {task_id}" if task_id else "" + self._logger.info( + "%s Completed Session %s | status: %s | score: %s | steps: %s | time: %.1fs%s\n" "logs: %s", + success_emoji, + session_number, + status, + score_text, + steps, + execution_time, + task_id_str, + session.paths.root, + ) + + def _log_error(self, where, session, error) -> None: + self._ensure_logger() + session_id = session.session_id if session else None + if session_id: + self._logger.error( + "error (%s) session=%s: %s", + where, + session_id, + error, + ) + else: + self._logger.error("error (%s): %s", where, error) + + def on_session_reuse(self, session_results: SessionResults) -> None: + self._ensure_logger() + session_id = session_results.session_id + session_number = self._ledger.mark_reuse(session_id) + + reason = "reused existing session" + success = bool(session_results.success) + value = session_results.score + is_finished = session_results.is_finished + score_text = f"{value}" + success_emoji = self._success_emoji(success, value, is_finished) + status = self._status_label(success, is_finished, reason) + task_id = session_results.task_id + task_id_str = f" | task_id: {task_id}" if task_id else "" + execution_time = float(session_results.execution_time or 0.0) + steps = int(session_results.steps or 0) + sess_paths = self.paths.session(session_id) + self._logger.info( + "⏭️ Reused Session %s from existing results.", + session_number, + ) + self._logger.info( + "%s Completed Session %s | status: %s | score: %s | steps: %s | time: %.1fs%s\n" "logs: %s", + success_emoji, + session_number, + status, + score_text, + steps, + execution_time, + task_id_str, + sess_paths.root, + ) + + @staticmethod + def _format_error_detail(error: Exception | None) -> str: + detail = error + if isinstance(error, (AgentError, BenchmarkError)): + detail = error.error + if detail is None: + return "unknown error" + text = str(detail) + if not text: + return type(detail).__name__ + if isinstance(detail, Exception): + return f"{type(detail).__name__}: {text}" + return text + + def _set_reason(self, session, reason: str, *, overwrite: bool = False) -> None: + session_id = session.session_id if session else None + if session_id is None: + return + with self._lock: + if session_id in self._session_reasons and not overwrite: + return + self._session_reasons[session_id] = reason + + def _pop_reason(self, session) -> str: + session_id = session.session_id if session else None + if session_id is None: + return "ended" + with self._lock: + return self._session_reasons.pop(session_id, "ended") + + @staticmethod + def _success_emoji(success: bool, value: float | None, is_finished: bool | None) -> str: + if success: + if value is not None and value == 1.0: + return "✅" + return "☑️ " + if is_finished is True: + return "☑️ " + if is_finished is False: + return "⚠️" + return "❌" + + @staticmethod + def _status_label(success: bool, is_finished: bool | None, reason: str) -> str: + if success: + return "success" + if is_finished is True: + return "unsuccessful" + if is_finished is False: + return "unfinished" + reason_lower = reason.lower() if reason else "" + if "illegal action" in reason_lower or "agent" in reason_lower: + return "agent error" + if ( + "benchmark" in reason_lower + or "session exception" in reason_lower + or "illegal observation" in reason_lower + or "observation returned from session" in reason_lower + ): + return "benchmark error" + if "cancelled" in reason_lower: + return "cancelled" + return "error" diff --git a/labs/AgentStream/exgentic/src/exgentic/observers/handlers/logger.py b/labs/AgentStream/exgentic/src/exgentic/observers/handlers/logger.py new file mode 100644 index 00000000..471b316d --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/observers/handlers/logger.py @@ -0,0 +1,508 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import json +import logging +import threading +from datetime import datetime, timedelta +from pathlib import Path +from typing import Dict + +from rich.columns import Columns +from rich.console import Console +from rich.panel import Panel +from rich.progress import ( + BarColumn, + Progress, + ProgressColumn, + SpinnerColumn, + TextColumn, +) +from rich.table import Table +from rich.text import Text + +from ...core.context import get_context +from ...core.orchestrator.observer import Observer +from ...core.orchestrator.termination import RunCancelError, SessionCancelError +from ...core.types import Action, ModelSettings, SessionOutcomeStatus +from ...utils.paths import RunPaths, get_run_paths, get_session_paths +from ...utils.settings import get_settings +from .recap import RunRecapMixin +from .session_ledger import SessionLedger + + +class _DurationColumn(ProgressColumn): + def render(self, task) -> Text: + duration = task.fields.get("duration") + if isinstance(duration, (int, float)): + elapsed = duration + else: + elapsed = task.finished_time if task.finished else task.elapsed + if elapsed is None: + return Text("-:--:--", style="progress.elapsed") + delta = timedelta(seconds=max(0, int(elapsed))) + return Text(str(delta), style="progress.elapsed") + + +class _CountColumn(ProgressColumn): + def render(self, task) -> Text: + unit = task.fields.get("unit") or "" + hide_total = bool(task.fields.get("hide_total")) + total = task.total + completed = int(task.completed or 0) + if hide_total or unit == "steps": + text = f"{completed} {unit}".strip() + elif total is None: + text = f"{completed} {unit}".strip() + else: + text = f"{completed}/{int(total)} {unit}".strip() + return Text(text, style="progress.remaining") + + +class ConsoleLoggerObserver(Observer, RunRecapMixin): + _MAX_REUSE_DURATION_SECONDS = 7 * 24 * 60 * 60 + _MAX_VISIBLE_SESSIONS = 10 + + def __init__(self, console: Console | None = None) -> None: + self._console = console or Console() + self._lock = threading.Lock() + self._ledger = SessionLedger() + self._start_time: datetime | None = None + self._progress: Progress | None = None + self._run_task_id: int | None = None + self._session_tasks: Dict[str, tuple[int, int | None]] = {} + self._completed_session_tasks: list[int] = [] + self._run_config = None + + def on_run_start(self, run_config) -> None: + if not self._enabled(logging.INFO): + return + self._run_config = run_config + self._start_time = datetime.now() + run_ctx = get_context() + run_id = run_ctx.run_id + + # Display OTEL configuration if enabled + settings = get_settings() + if settings.otel_enabled: + import os + + endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "not set") + protocol = os.getenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf") + service_name = os.getenv("OTEL_SERVICE_NAME", "exgentic") + record_content = "yes" if settings.otel_record_content else "no" + + otel_lines = [ + "[bold cyan]📊 OpenTelemetry Tracing ENABLED[/bold cyan]", + f"[bold]Service Name:[/bold] {service_name}", + f"[bold]Collector:[/bold] {endpoint}", + f"[bold]Protocol:[/bold] {protocol}", + f"[bold]Record Content:[/bold] {record_content}", + ] + otel_body = "\n".join(otel_lines) + self._print(Panel(otel_body, border_style="cyan", padding=(1, 2), title="OpenTelemetry")) + + lines = [f"[bold]Run:[/bold] [cyan]{run_id}[/cyan]"] + overrides = {} + if run_config.max_steps != 100: + overrides["max_steps"] = str(run_config.max_steps) + if run_config.max_actions != 100: + overrides["max_actions"] = str(run_config.max_actions) + if run_config.max_workers is not None: + overrides["max_workers"] = str(run_config.max_workers) + if overrides: + for key in sorted(overrides): + lines.append(f"[bold]{key}:[/bold] {overrides[key]}") + body = "\n".join(lines) + "\n" + title = Text("EXGENTIC", style="bold magenta") + self._print(Panel(body, border_style="magenta", padding=(1, 2), title=title)) + config_panels = self._build_config_panels() + if config_panels: + self._print(config_panels) + self._start_progress(run_config) + + def on_session_start(self, session, agent, observation) -> None: + if not self._enabled(logging.INFO): + return + session_id = session.session_id + session_number = self._ledger.register(session_id) + self._start_session_progress(session_id, session_number, agent, session) + + def on_react_success(self, session, action) -> None: + if not isinstance(action, Action): + return + if not self._enabled(logging.INFO): + return + session_id = session.session_id + self._ledger.increment_steps(session_id) + self._advance_session_progress(session_id) + + def on_session_success(self, session, score, agent) -> None: + if not self._enabled(logging.INFO): + return + session_id = session.session_id + session_number = self._ledger.get_number(session_id) + limit_reached = False + try: + limit_reached = bool(score.session_metadata.get("limit_reached")) + except Exception: + limit_reached = False + status = None + if limit_reached and not (score.is_finished is True and bool(score.success)): + status = SessionOutcomeStatus.LIMIT_REACHED + outcome = self._format_outcome( + status=status, + success=bool(score.success), + is_finished=score.is_finished, + ) + if outcome == "success": + link = self._format_path_link(session.paths.root) + desc = f"Session {session_number} ✔ success ({link})" + color = "green" + else: + link = self._format_path_link(session.paths.root) + desc = f"Session {session_number} ⏹ {outcome} ({link})" + color = "yellow" + self._update_session_progress_description(session_id, f"[{color}]{desc}[/{color}]") + self._stop_session_progress(session_id) + self._advance_run_progress() + + def on_session_scoring(self, session) -> None: + if not self._enabled(logging.INFO): + return + session_id = session.session_id + session_number = self._ledger.get_number(session_id) + link = self._format_path_link(session.paths.root) + desc = f"Session {session_number} ⏳ scoring ({link})" + self._update_session_progress_description(session_id, f"[yellow]{desc}[/yellow]") + if self._progress is None: + self._print(Text.from_markup(f"[yellow]{desc}[/yellow]")) + else: + self._progress.refresh() + + def on_session_error(self, session, error) -> None: + session_id = session.session_id if session else None + session_root = None + if session is not None: + session_root = session.paths.root + elif session_id is not None: + try: + ctx = get_context() + session_root = RunPaths.from_context(ctx).session(session_id).root + except RuntimeError: + pass + session_number = self._ledger.get_number(session_id) + if not self._enabled(logging.INFO): + return + if isinstance(error, (SessionCancelError, RunCancelError, KeyboardInterrupt)): + desc = f"[yellow]Session {session_number} ⏹ cancelled" + else: + desc = f"[red]Session {session_number} ✖ error" + if session_root is not None: + link = self._format_path_link(session_root) + desc = f"{desc} ({link})" + if desc.startswith("[red]"): + desc = f"{desc}[/red]" + else: + desc = f"{desc}[/yellow]" + self._update_session_progress_description(session_id, desc) + self._stop_session_progress(session_id) + self._advance_run_progress() + + def on_session_reuse(self, session_results) -> None: + if not self._enabled(logging.INFO): + return + session_id = session_results.session_id + session_number = self._ledger.mark_reuse(session_id) + steps = session_results.steps + status = session_results.status + execution_time = session_results.execution_time + if ( + not isinstance(execution_time, (int, float)) + or execution_time < 0 + or execution_time > self._MAX_REUSE_DURATION_SECONDS + ): + execution_time = None + outcome = self._format_outcome( + status=status, + success=session_results.success, + is_finished=session_results.is_finished, + ) + detail_parts = ["↺ reused", outcome] + detail_text = " ".join(detail_parts) + session_root = get_session_paths(session_id).root + link = self._format_path_link(session_root) + desc = f"[yellow]Session {session_number} {detail_text} " f"({link})[/yellow]" + total = steps if isinstance(steps, int) and steps > 0 else 1 + self._add_completed_session_task( + desc, + total=total, + duration=execution_time if isinstance(execution_time, (int, float)) else None, + ) + self._advance_run_progress() + + def on_run_success(self, results, run_config) -> None: + self._stop_progress() + self._print_recap() + + def on_run_error(self, error) -> None: + self._stop_progress() + self._print_recap() + + def _print_recap(self) -> None: + if not self._enabled(logging.INFO): + return + data = self._load_recap_data(get_run_paths().results, self._start_time) + if data is None: + return + table = Table(show_header=False, box=None, pad_edge=False) + table.add_row( + "[bold]Sessions[/bold]", + f"{data.total_sessions} (successes: {data.successful_sessions})", + ) + if data.success_rate is not None: + table.add_row( + "[bold]Success %[/bold]", + f"{data.success_rate:.2%}", + ) + if data.finished_sessions is not None: + table.add_row( + "[bold]Finished[/bold]", + f"{data.finished_sessions}", + ) + table.add_row("[bold]Avg steps[/bold]", f"{data.average_steps}") + self._print(Panel(table, border_style="magenta", title="Recap")) + + cost_table = Table(show_header=False, box=None, pad_edge=False) + cost_table.add_row( + "[bold]Run[/bold]", + f"{self._format_money(data.run_cost)}", + ) + cost_table.add_row( + "[bold]Avg agent[/bold]", + f"{self._format_money(data.avg_agent_cost)}", + ) + self._print(Panel(cost_table, border_style="magenta", title="Costs")) + + results_table = Table(show_header=False, box=None, pad_edge=False) + results_table.add_row("[bold]Results[/bold]", f"{data.results_path}") + self._print(Panel(results_table, border_style="magenta", title="Results")) + + def _enabled(self, level: int) -> bool: + configured = logging._nameToLevel.get(get_settings().log_level.upper(), logging.INFO) + return level >= configured + + def _format_value(self, value) -> str: + if isinstance(value, (dict, list, tuple)): + try: + return json.dumps(value, ensure_ascii=True) + except TypeError: + return str(value) + return str(value) + + def _build_config_panels(self): + if self._run_config is None: + return None + bench_overrides = dict(self._run_config.benchmark_kwargs or {}) + agent_overrides = dict(self._run_config.agent_kwargs or {}) + model_value = self._run_config.model or agent_overrides.get("model") or "unknown" + model_settings = agent_overrides.pop("model_settings", None) + agent_overrides = { + "model": str(model_value), + **agent_overrides, + } + if model_settings: + if hasattr(model_settings, "model_dump"): + model_settings = model_settings.model_dump(exclude_none=True) + if isinstance(model_settings, dict): + default_settings = ModelSettings().model_dump(exclude_none=True) + for key, value in model_settings.items(): + if value is None: + continue + if default_settings.get(key) == value: + continue + agent_overrides[f"model.{key}"] = value + + bench_name = self._run_config.benchmark + agent_name = self._run_config.agent + bench_panel = self._build_config_panel(f"Benchmark: {bench_name}", bench_overrides, border_style="cyan") + agent_panel = self._build_config_panel(f"Agent: {agent_name}", agent_overrides, border_style="green") + console_width = self._console.width + gap = 2 + panel_width = max(20, (console_width - gap) // 2) + bench_panel.width = panel_width + agent_panel.width = panel_width + return Columns([bench_panel, agent_panel], equal=True, expand=True) + + def _build_config_panel( + self, + title: str, + overrides: Dict[str, str], + *, + border_style: str = "magenta", + ) -> Panel: + table = Table(show_header=False, box=None, pad_edge=False) + if overrides: + for key in sorted(overrides): + table.add_row(f"[bold]{key}[/bold]", self._format_value(overrides[key])) + else: + table.add_row("[dim]no overrides[/dim]", "") + return Panel(table, border_style=border_style, title=title) + + def _format_money(self, value: float | None) -> str: + if value is None: + return "-" + return f"${value:.1f}" + + def _format_score(self, value) -> str: + if value is None: + return "-" + if isinstance(value, (int, float)): + return f"{value:.2f}" + return str(value) + + def _format_outcome( + self, + *, + status=None, + success: bool | None = None, + is_finished: bool | None = None, + ) -> str: + if status is not None: + return str(status) + if is_finished is False: + return "unfinished" + if is_finished is True: + return "success" if success else "unsuccessful" + return "unknown" + + @staticmethod + def _format_path_link(path, *, max_len: int = 80) -> str: + text = str(path) + if len(text) > max_len and max_len > 3: + text = "..." + text[-(max_len - 3) :] + try: + target = str(Path(path).resolve()) + except Exception: + return text + return f"[link={target}]{text}[/link]" + + def _print(self, renderable) -> None: + with self._lock: + if self._progress is not None: + self._progress.console.print(renderable) + else: + self._console.print(renderable) + + def _start_progress(self, run_config) -> None: + total = None + if run_config is not None: + if run_config.task_ids: + total = len(run_config.task_ids) + if run_config.num_tasks is not None: + total = min(total, int(run_config.num_tasks)) + elif run_config.num_tasks is not None: + total = int(run_config.num_tasks) + self._progress = Progress( + SpinnerColumn(), + TextColumn("[bold]{task.description}[/bold]"), + BarColumn(bar_width=None), + _CountColumn(), + _DurationColumn(), + console=self._console, + transient=False, + ) + self._progress.start() + self._run_task_id = self._progress.add_task("Run", total=total if total else None, unit="sessions") + + def _start_session_progress(self, session_id: str, session_number: int, agent, session) -> None: + if self._progress is None: + return + total = agent.max_steps + task_id = self._progress.add_task( + f"Session {session_number} ({self._format_path_link(session.paths.root)})", + total=total if total else None, + unit="steps", + hide_total=True, + ) + self._session_tasks[session_id] = (task_id, total) + + def _update_session_progress_description(self, session_id: str, description: str) -> None: + if self._progress is None: + return + entry = self._session_tasks.get(session_id) + if entry is None: + return + task_id, _ = entry + self._progress.update(task_id, description=description) + + def _add_completed_session_task( + self, + description: str, + total: int, + duration: float | None = None, + ) -> None: + if self._progress is None: + return + task_id = self._progress.add_task( + description, + total=total, + unit="steps", + duration=duration, + ) + self._progress.update(task_id, completed=total) + self._progress.stop_task(task_id) + self._track_completed_session_task(task_id) + + def _advance_session_progress(self, session_id: str) -> None: + if self._progress is None: + return + entry = self._session_tasks.get(session_id) + if entry is None: + return + task_id, _ = entry + self._progress.update(task_id, advance=1) + + def _stop_session_progress(self, session_id: str) -> None: + if self._progress is None: + return + entry = self._session_tasks.pop(session_id, None) + if entry is None: + return + task_id, total = entry + steps = self._ledger.get_steps(session_id) + if steps > 0: + self._progress.update(task_id, total=steps, completed=steps) + else: + self._progress.update(task_id, completed=0) + self._progress.stop_task(task_id) + self._track_completed_session_task(task_id) + + def _track_completed_session_task(self, task_id: int) -> None: + if self._progress is None: + return + self._completed_session_tasks.append(task_id) + excess = len(self._completed_session_tasks) - self._MAX_VISIBLE_SESSIONS + if excess <= 0: + return + for _ in range(excess): + old_id = self._completed_session_tasks.pop(0) + if old_id == self._run_task_id: + continue + try: + self._progress.remove_task(old_id) + except Exception: + continue + + def _advance_run_progress(self) -> None: + if self._progress is None or self._run_task_id is None: + return + self._progress.update(self._run_task_id, advance=1) + + def _stop_progress(self) -> None: + if self._progress is None: + return + self._progress.stop() + self._progress = None + self._run_task_id = None + self._session_tasks = {} diff --git a/labs/AgentStream/exgentic/src/exgentic/observers/handlers/otel.py b/labs/AgentStream/exgentic/src/exgentic/observers/handlers/otel.py new file mode 100644 index 00000000..4f6c717f --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/observers/handlers/otel.py @@ -0,0 +1,451 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import json +import os +import threading +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Optional, cast + +from opentelemetry import context, trace +from opentelemetry.sdk.trace import Span +from opentelemetry.trace import SpanKind, Tracer +from opentelemetry.trace.status import Status, StatusCode +from opentelemetry.util.types import AttributeValue + +from ...core.context import OtelContext, get_context, set_context +from ...core.orchestrator.observer import Observer +from ...interfaces.registry import get_agent_entries, get_benchmark_entries +from ...utils.otel import ( + flush_traces, + get_session_logger, + init_tracing_from_env, + to_otel_attribute_value, +) +from ...utils.settings import get_settings + +tracer = init_tracing_from_env() + + +class SessionSpanManager: + """Manages isolated span hierarchy for a single session.""" + + def __init__(self, session_id: str, session_root: Path, tracer: Tracer = tracer): + self.session_id = session_id + self._tracer = tracer + self._span_stack: list[Span] = [] + self._heritable_attributes: Dict[str, AttributeValue] = {} + + # Initialize session logger + self._logger = get_session_logger( + session_root, + f"{__name__} | pid={os.getpid()} tid={threading.get_native_id()}", + ) + self._logger.info(f"SessionSpanManager initialized for session {self.session_id}") + + def start_span(self, name: str, **kwargs) -> Span: + """Start a new span as a child of the current span. + + Args: + name: Name of the span + **kwargs: Additional arguments passed to tracer.start_span + """ + parent_span = self._span_stack[-1] if self._span_stack else None + ctx = trace.set_span_in_context(parent_span) if parent_span else context.get_current() + + span = cast(Span, self._tracer.start_span(name, context=ctx, **kwargs)) + self._span_stack.append(span) + + # Apply heritable attributes to new span + for k, v in self._heritable_attributes.items(): + span.set_attribute(k, v) + + # Log span start + span_ctx = span.get_span_context() + parent_span_id = format(parent_span.get_span_context().span_id, "016x") if parent_span else None + start_time = datetime.fromtimestamp(span.start_time / 1_000_000_000) + + self._logger.log_span_start( + span_name=name, + span_id=format(span_ctx.span_id, "016x"), + trace_id=format(span_ctx.trace_id, "032x"), + parent_span_id=parent_span_id, + is_root=(len(self._span_stack) == 1), + depth=len(self._span_stack), + start_time=start_time, + ) + return span + + def end_current_span(self) -> None: + """End the current span and set parent as current.""" + if not self._span_stack: + self._logger.warning("Attempted to end span with empty stack") + return + + span = self._span_stack.pop() + span_ctx = span.get_span_context() + span_name = getattr(span, "_name", "unknown") # Try to get span name + + span.end() + end_time = datetime.fromtimestamp(span.end_time / 1_000_000_000) + + self._logger.log_span_end( + span_name=span_name, + span_id=format(span_ctx.span_id, "016x"), + is_root=(len(self._span_stack) == 0), + depth=len(self._span_stack), + end_time=end_time, + ) + + @property + def current_span(self) -> Optional[Span]: + return self._span_stack[-1] if self._span_stack else None + + def get_otel_context(self) -> Optional[OtelContext]: + """Export current span context for subprocess transport. + + Returns: + OtelContext with trace_id and span_id as hex strings, or None if no current span. + """ + if not self.current_span: + return None + + span_ctx = self.current_span.get_span_context() + return OtelContext( + trace_id=format(span_ctx.trace_id, "032x"), + span_id=format(span_ctx.span_id, "016x"), + ) + + def update_tracing_context(self) -> None: + """Update the global Context with current OTEL span context.""" + otel_context = self.get_otel_context() + ctx = get_context() + new_ctx = ctx.with_otel_context(otel_context) + set_context(new_ctx) + self._logger.log_context_update( + otel_context.trace_id if otel_context is not None else None, + otel_context.span_id if otel_context is not None else None, + operation="write", + ) + + def set_attribute(self, key: str, value: AttributeValue) -> None: + if self.current_span is None: + raise (AttributeError("No current span")) + self.current_span.set_attribute(key, value) + span_ctx = self.current_span.get_span_context() + self._logger.log_attribute_set(key, value, format(span_ctx.span_id, "016x")) + + def set_attributes(self, attributes: Optional[Dict[str, AttributeValue]] = None, **kwargs) -> None: + if attributes is None: + attributes = {} + attributes.update(kwargs) + for k, v in attributes.items(): + self.set_attribute(k, v) + + def set_heritable_attribute(self, key: str, value: AttributeValue) -> None: + self._heritable_attributes[key] = value + if self.current_span: + self.set_attribute(key, value) + + def set_heritable_attributes(self, attributes: Optional[Dict[str, AttributeValue]] = None, **kwargs) -> None: + if attributes: + self._heritable_attributes.update(attributes) + self._heritable_attributes.update(kwargs) + if self.current_span: + self.set_attributes(attributes, **kwargs) + + def record_exception(self, exc: Exception, set_error_status: bool = True) -> None: + if self.current_span: + self.current_span.record_exception(exc) + if set_error_status: + self.current_span.set_status(Status(StatusCode.ERROR)) + self._logger.log_exception(exc) + + def update_current_span_name(self, new_name: str) -> None: + if self.current_span: + old_name = getattr(self.current_span, "_name", "unknown") + self.current_span.update_name(new_name) + span_ctx = self.current_span.get_span_context() + self._logger.log_span_rename(old_name, new_name, format(span_ctx.span_id, "016x")) + + +# OBSERVER +class OtelTracingObserver(Observer): + """OpenTelemetry tracing observer for sessions. + + This observer creates a complete trace for each session, with spans for: + - Session (root span) + - Steps (child spans) + - Actions and observations (nested child spans) + + Each session gets its own SessionSpanContext instance, ensuring complete + isolation between concurrent sessions + """ + + def __init__(self): + super().__init__() + self._run_attributes: Dict[str, AttributeValue] = {} + self._span_managers: Dict[str, SessionSpanManager] = {} + self._session_step_counters: Dict[str, int] = {} + self._session_agents: Dict[str, Any] = {} # Store agent instances by session_id + self._session_actions: Dict[str, list] = {} # Store session actions for tool definitions + + def _get_span_manager(self, session_id: str) -> SessionSpanManager: + return self._span_managers[session_id] + + def _get_action_description(self, session, action_name: str) -> Optional[str]: + """Look up action description from session.actions by name.""" + for action_type in session.actions: + if action_type.name == action_name: + return action_type.description + return None + + def _get_tool_definitions(self, session_id: str) -> str: + """Generate gen_ai.tool.definitions JSON from session actions.""" + actions = self._session_actions.get(session_id, []) + tool_definitions = [] + + for action_type in actions: + tool_def = { + "type": "function", + "function": { + "name": action_type.name, + "description": action_type.description, + }, + } + + # Add parameters schema if available + try: + schema = action_type.arguments.model_json_schema() + # Convert to OpenAI function calling format + tool_def["function"]["parameters"] = { + "type": "object", + "properties": schema.get("properties", {}), + "required": schema.get("required", []), + } + except Exception: + pass + + tool_definitions.append(tool_def) + + try: + return json.dumps(tool_definitions) + except Exception: + return "[]" + + def on_run_start(self, run_config) -> None: + bench_entry = get_benchmark_entries().get(run_config.benchmark) + agent_entry = get_agent_entries().get(run_config.agent) + + # Extract model name from run_config + model_name = run_config.model or (run_config.agent_kwargs or {}).get("model") + + from ...utils.paths import get_run_paths + + self._run_attributes = { + "exgentic.benchmark.slug_name": bench_entry.slug_name if bench_entry is not None else run_config.benchmark, + "exgentic.benchmark.subset": run_config.subset, + "exgentic.benchmark.agent.name": agent_entry.slug_name if agent_entry is not None else run_config.agent, + "exgentic.agent.slug": run_config.agent, + "exgentic.run.id": get_run_paths().run_id, + } + + # Store model name as heritable attribute + if model_name: + self._run_attributes["gen_ai.request.model"] = model_name + + def on_session_creation(self, session) -> None: + span_manager = SessionSpanManager(session.session_id, self.paths.session(session.session_id).root) + self._span_managers[session.session_id] = span_manager + self._session_step_counters[session.session_id] = 0 + self._session_actions[session.session_id] = session.actions # Store actions for tool definitions + + # Start root session span + bench_name = self._run_attributes.get("exgentic.benchmark.slug_name", "unknown_benchmark") + subset = self._run_attributes.get("exgentic.benchmark.subset", "subset") + span_manager.start_span(f"{bench_name} {subset} session") + span_manager.update_tracing_context() # pass otel context to trace_logger + + span_manager.set_heritable_attributes(self._run_attributes) + + # Set session-level attributes + # gen_ai.conversation.id is the primary correlation attribute (heritable) + span_manager.set_heritable_attribute( + "gen_ai.conversation.id", + session.session_id, + ) + # Also keep exgentic.session.id for backwards compatibility + span_manager.set_heritable_attribute( + "exgentic.session.id", + session.session_id, + ) + span_manager.set_attribute("exgentic.session.task_id", session.task_id) + + # Only record task content if otel_record_content is enabled + if get_settings().otel_record_content: + span_manager.set_attribute( + "exgentic.session.task", + session.task, + ) + + for action in session.actions: + span_manager.set_attribute(f"exgentic.session.action.{action.name}.name", action.name) + span_manager.set_attribute(f"exgentic.session.action.{action.name}.description", action.description) + span_manager.set_attribute(f"exgentic.session.action.{action.name}.is_message", action.is_message) + span_manager.set_attribute(f"exgentic.session.action.{action.name}.is_finish", action.is_finish) + for k, v in session.context.items(): + otel_value = to_otel_attribute_value(v) + if otel_value is not None: + span_manager.set_attribute(f"exgentic.context.{k}", otel_value) + + def on_session_start(self, session, agent, observation) -> None: + self._session_agents[session.session_id] = agent # Store agent instance + span_manager = self._span_managers[session.session_id] + + span_manager.set_attribute("exgentic.session.agent.id", agent.agent_id) + agent_path_otel = to_otel_attribute_value(agent.paths.agent_dir) + if agent_path_otel is not None: + span_manager.set_attribute("exgentic.session.agent.path", agent_path_otel) + + # Record initial observation as execute_tool span + span_manager.start_span("execute_tool initial_observation", kind=SpanKind.CLIENT) + span_manager.current_span.set_attribute("gen_ai.operation.name", "execute_tool") + span_manager.current_span.set_attribute("gen_ai.tool.name", "initial_observation") + span_manager.current_span.set_attribute("gen_ai.tool.description", "Initial observation from benchmark") + + self._record_observation(session.session_id, observation) + span_manager.end_current_span() # end initial observation span + + # Increment step counter (no invoke_agent span created) + self._session_step_counters[session.session_id] += 1 + + def _record_observation(self, session_id: str, observation) -> None: + """Record observation details on the current span.""" + span_manager = self._get_span_manager(session_id) + observation_list = observation.to_observation_list() if observation is not None else [] + + # Only record observation content if otel_record_content is enabled + if get_settings().otel_record_content: + observation_otel = to_otel_attribute_value(observation_list) + if observation_otel is not None: + span_manager.current_span.set_attribute("gen_ai.tool.result", observation_otel) + + def on_react_success(self, session, action) -> None: + span_manager = self._get_span_manager(session.session_id) + + # Create execute_tool span with semantic conventions + action_list = action.to_action_list() if action else [] + tool_name = action_list[0].name if action_list and action_list[0] else "unknown" + span_manager.start_span(f"execute_tool {tool_name}", kind=SpanKind.CLIENT) + + # Set required semantic convention attributes + span_manager.current_span.set_attribute("gen_ai.operation.name", "execute_tool") + span_manager.current_span.set_attribute("gen_ai.tool.name", tool_name) + + # Set recommended attributes + if action_list: + first_action = action_list[0] + span_manager.current_span.set_attribute("gen_ai.tool.id", first_action.id) + + # Get tool description from session.actions + tool_desc = self._get_action_description(session, tool_name) + if tool_desc: + span_manager.current_span.set_attribute("gen_ai.tool.description", tool_desc) + + # Set tool parameters as JSON + if get_settings().otel_record_content: + try: + params_json = first_action.arguments.model_dump_json() + span_manager.current_span.set_attribute("gen_ai.tool.parameters", params_json) + except Exception: + pass + + def on_react_error(self, session, error) -> None: + return None + + def on_step_success(self, session, observation) -> None: + span_manager = self._get_span_manager(session.session_id) + self._record_observation(session.session_id, observation) + span_manager.end_current_span() # end execute_tool span + + self._session_step_counters[session.session_id] += 1 + + def on_step_error(self, session, error) -> None: + span_manager = self._get_span_manager(session.session_id) + span_manager.record_exception(error) + + def on_session_success(self, session, score, agent) -> None: + span_manager = self._get_span_manager(session.session_id) + + # Certain session conditions may lead to a trailing execute_tool span + if len(span_manager._span_stack) == 2: + span_manager.end_current_span() # end execute_tool span + + # Add final session attributes (with exgentic. prefix) + span_manager.set_attribute("exgentic.score.success", score.success) + span_manager.set_attribute("exgentic.score", score.score) + span_manager.set_attribute("exgentic.score.is_finished", score.is_finished) + span_manager.set_attribute("exgentic.session.steps", self._session_step_counters[session.session_id]) + + # Convert cost objects to JSON strings for OTEL compatibility + try: + agent_cost = agent.get_cost() + span_manager.set_attribute("exgentic.agent.agent_cost", json.dumps(agent_cost, default=str)) + except Exception: + pass + + try: + session_cost = session.get_cost() + span_manager.set_attribute("exgentic.session.cost", json.dumps(session_cost, default=str)) + except Exception: + pass + + span_manager.set_attribute("exgentic.session.task_id", session.task_id) + + # Close session span + span_manager.end_current_span() + + # Flush traces to ensure they are exported + flush_traces() + + # Clean up + del self._span_managers[session.session_id] + del self._session_step_counters[session.session_id] + del self._session_agents[session.session_id] + del self._session_actions[session.session_id] + + def on_session_error(self, session, error) -> None: + span_manager = self._get_span_manager(session.session_id) + + # Certain session conditions may lead to a trailing execute_tool span + if len(span_manager._span_stack) == 2: + span_manager.end_current_span() # end execute_tool span + + # Record error on session span + span_manager.record_exception(error) + + # Convert cost object to JSON string for OTEL compatibility + try: + session_cost = session.get_cost() + span_manager.set_attribute("exgentic.session.cost", json.dumps(session_cost, default=str)) + except Exception: + pass + + span_manager.set_attribute("exgentic.session.task_id", session.task_id) + + span_manager.end_current_span() # Close session span + + # Flush traces to ensure they are exported + flush_traces() + + # Clean up + del self._span_managers[session.session_id] + del self._session_step_counters[session.session_id] + if session.session_id in self._session_agents: + del self._session_agents[session.session_id] + if session.session_id in self._session_actions: + del self._session_actions[session.session_id] + + +# Made with Bob diff --git a/labs/AgentStream/exgentic/src/exgentic/observers/handlers/recap.py b/labs/AgentStream/exgentic/src/exgentic/observers/handlers/recap.py new file mode 100644 index 00000000..f5d9b7ba --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/observers/handlers/recap.py @@ -0,0 +1,145 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import json +from dataclasses import dataclass +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any + +from ...core.orchestrator.observer import Observer +from ..logging import get_logger + + +@dataclass(frozen=True) +class RunRecapData: + total_sessions: int + successful_sessions: int + success_rate: float | None + finished_sessions: int | None + benchmark_score: Any + average_score: Any + run_cost: Any + avg_agent_cost: Any + total_agent_cost: Any + total_benchmark_cost: Any + average_steps: Any + execution_time: timedelta + results_path: Path + + +class RunRecapMixin: + def _load_recap_data(self, results_path: Path, start_time: datetime | None) -> RunRecapData | None: + if not results_path.exists(): + return None + + try: + with open(results_path, encoding="utf-8") as f: + results = json.load(f) + except Exception: + return None + + total = results.get("total_sessions", 0) + succ = results.get("successful_sessions", 0) + success_rate = results.get("percent_successful") + final = results.get("benchmark_score") + avg = results.get("average_score") + run_cost = results.get("total_run_cost") + avg_agent_cost = results.get("average_agent_cost") + total_agent_cost = results.get("total_agent_cost") + total_benchmark_cost = results.get("total_benchmark_cost") + avg_steps = results.get("average_steps") + + session_results = results.get("session_results") or [] + if session_results: + total = len(session_results) + succ = sum(1 for r in session_results if r.get("success")) + if total: + success_rate = succ / total + finished = sum(1 for r in session_results if r.get("is_finished")) if session_results else None + + started = start_time or datetime.now() + execution_time = datetime.now() - started + return RunRecapData( + total_sessions=total, + successful_sessions=succ, + success_rate=success_rate, + finished_sessions=finished, + benchmark_score=final, + average_score=avg, + run_cost=run_cost, + avg_agent_cost=avg_agent_cost, + total_agent_cost=total_agent_cost, + total_benchmark_cost=total_benchmark_cost, + average_steps=avg_steps, + execution_time=execution_time, + results_path=results_path, + ) + + def _format_money(self, value: Any) -> str: + return f"${value:.4f}" if isinstance(value, (int, float)) else "-" + + +class RunRecapObserver(Observer, RunRecapMixin): + def __init__( + self, + run_id: str | None = None, + *, + console: bool = False, + logger=None, + ) -> None: + super().__init__(run_id) + self._logger = logger + self._console = console + self._start_time: datetime | None = None + + def _ensure_logger(self) -> None: + if self._logger is not None: + return + rp = self.paths + log_path = rp.tracker + self._logger = get_logger( + f"tracker.recap.{self._run_id}", + str(log_path), + console=self._console, + propagate=False, + ) + + def on_run_start(self, run_config) -> None: + self._ensure_logger() + self._start_time = datetime.now() + + def on_run_success(self, results, run_config) -> None: + self._log_recap() + + def on_run_error(self, error) -> None: + self._log_recap() + + def _log_recap(self) -> None: + self._ensure_logger() + results_path = self.paths.results + data = self._load_recap_data(results_path, self._start_time) + if data is None: + return + + finished_str = f" | Finished: {data.finished_sessions}" if data.finished_sessions is not None else "" + success_rate_str = f" | Success%: {data.success_rate:.2%}" if data.success_rate is not None else "" + + final_str = f"{data.benchmark_score}" if data.benchmark_score is not None else "-" + avg_str = f"{data.average_score}" if data.average_score is not None else "-" + run_cost_str = self._format_money(data.run_cost) + avg_agent_cost_str = self._format_money(data.avg_agent_cost) + total_agent_cost_str = self._format_money(data.total_agent_cost) + total_benchmark_cost_str = self._format_money(data.total_benchmark_cost) + + recap = ( + f"📊 Sessions: {data.total_sessions} | Successes: {data.successful_sessions}" + f"{success_rate_str}{finished_str}\n" + f"🏁 Scores: Final={final_str} | Avg={avg_str}\n" + f"💰 Costs: Run={run_cost_str} | Avg Agent={avg_agent_cost_str} | " + f"Agent Total={total_agent_cost_str} | Benchmark Total={total_benchmark_cost_str}\n" + f"🐾 Average number of steps={data.average_steps}\n" + f"🕐 Total execution time={data.execution_time}\n" + f"📄 Results: {data.results_path}" + ) + self._logger.info(recap) diff --git a/labs/AgentStream/exgentic/src/exgentic/observers/handlers/results.py b/labs/AgentStream/exgentic/src/exgentic/observers/handlers/results.py new file mode 100644 index 00000000..4a69a0a3 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/observers/handlers/results.py @@ -0,0 +1,537 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import json +import os +import threading +import time +from dataclasses import dataclass +from typing import Any, Dict, Optional + +from ... import __version__ as exgentic_version +from ...core.orchestrator.observer import Observer +from ...core.orchestrator.termination import ( + AgentError, + BenchmarkError, + InvalidActionError, + InvalidObservationError, + RunCancelError, + SessionCancelError, +) +from ...core.types import ( + Action, + BenchmarkResults, + Observation, + RunResults, + RunStatus, + SessionExecutionStatus, + SessionOutcomeStatus, + SessionResults, + SessionScore, +) +from ...interfaces.registry import get_agent_entries, get_benchmark_entries +from ...utils.cost import CostReport, accumulate_reports +from .session_ledger import SessionLedger + + +@dataclass +class _SessionData: + action_count: int = 0 + invalid_action_count: int = 0 + agent: Any | None = None + reason: Optional[str] = None + + +class ResultsObserver(Observer): + def __init__(self, run_id: str | None = None) -> None: + super().__init__(run_id) + self._lock = threading.Lock() + self._ledger = SessionLedger() + self._sessions: Dict[str, _SessionData] = {} + self._session_results: list[SessionResults] = [] + self._results: RunResults | None = None + self._run_config: Optional[Any] = None + self._final_results: Optional[Any] = None + + def on_run_start(self, run_config) -> None: + with self._lock: + self._run_config = run_config + if self._run_id is None: + self._run_id = run_config.run_id + + def on_session_start(self, session, agent, observation) -> None: + session_id = session.session_id + self._ledger.register(session_id) + with self._lock: + self._sessions[session_id] = _SessionData(agent=agent) + if isinstance(observation, Observation): + self._record_observation(session, observation, step=0, initial=True) + + def on_react_success(self, session, action) -> None: + if action is None: + self._set_reason(session, "ended by benchmark (agent returned None action)") + return + if not isinstance(action, Action): + self._set_reason( + session, + f"terminated by illegal action returned from agent: {action}", + ) + return + self._record_action(session, action) + + def on_step_success(self, session, observation) -> None: + if observation is not None and not isinstance(observation, Observation): + self._set_reason( + session, + "terminated by illegal observation returned from session: " f"{observation}", + ) + return + self._record_observation(session, observation) + if observation is None: + self._set_reason(session, "ended by agent (session returned None observation)") + + def on_react_error(self, session, error) -> None: + if isinstance(error, InvalidActionError): + self._set_reason( + session, + f"terminated by illegal action returned from agent: {error.action}", + ) + else: + self._set_reason(session, "terminated by agent exception") + + def on_step_error(self, session, error) -> None: + if isinstance(error, InvalidObservationError): + self._set_reason( + session, + "terminated by illegal observation returned from session: " f"{error.observation}", + ) + else: + self._set_reason(session, "terminated by session exception") + + def on_session_error(self, session, error) -> None: + error_source = None + if isinstance(error, AgentError): + error_source = "agent" + elif isinstance(error, BenchmarkError): + error_source = "benchmark" + if isinstance(error, (SessionCancelError, RunCancelError, KeyboardInterrupt)): + self._set_reason(session, "cancelled by user") + error_source = "cancelled" + else: + self._set_reason( + session, + "terminated by unexpected exception (see console)", + ) + root_error = error.error if isinstance(error, (AgentError, BenchmarkError)) else None + error_message = str(root_error) if root_error else str(error) + session_metadata = {"error": error_message} + if error_source is not None: + session_metadata["error_source"] = error_source + score = SessionScore( + score=0, + success=False, + is_finished=None, + session_metadata=session_metadata, + ) + self._record_session(session, score) + + def on_session_success(self, session, score, agent) -> None: + self._record_session(session, score, agent=agent) + + def on_run_success(self, results, run_config) -> None: + with self._lock: + self._final_results = results + self._run_config = run_config + self._results = self._write_run_results() + + def on_run_error(self, error) -> None: + self._results = self._write_run_results() + + def results(self) -> RunResults: + if self._results is None: + raise RuntimeError("Run results have not been computed yet.") + return self._results + + def session_results(self) -> list[SessionResults]: + return list(self._session_results) + + def on_session_reuse(self, session_results: SessionResults) -> None: + with self._lock: + self._session_results.append(session_results) + + def _record_action(self, session, action: Action) -> int: + session_id = session.session_id + single_actions = action.to_action_list() + total_actions = len(single_actions) + invalid_actions = 0 + for single_action in single_actions: + report = single_action.validation + if not report.valid or not report.name_valid or not report.args_valid: + invalid_actions += 1 + step_n = self._ledger.increment_steps(session_id) + session_number = self._ledger.get_number(session_id) + with self._lock: + data = self._sessions.get(session_id) + if data is None: + data = _SessionData() + self._sessions[session_id] = data + data.action_count += total_actions + data.invalid_action_count += invalid_actions + agent_cost, benchmark_cost = self._get_cost_snapshot(session_id, session) + traj_path = session.paths.trajectory + traj_path.parent.mkdir(parents=True, exist_ok=True) + event = { + "event": "action", + "run_id": self._run_id, + "session_id": session_id, + "session_number": session_number, + "task_id": session.task_id, + "step": step_n, + "action": json.loads(action.model_dump_json()), + "initial": False, + "agent_cost": agent_cost, + "benchmark_cost": benchmark_cost, + } + with open(traj_path, "a", encoding="utf-8") as f: + json.dump(event, f, ensure_ascii=False) + f.write("\n") + return step_n + + def _record_observation( + self, + session, + observation: Optional[Observation], + *, + step: Optional[int] = None, + initial: bool = False, + ) -> None: + session_id = session.session_id + session_number = self._ledger.get_number(session_id) + step_n = self._ledger.get_steps(session_id) if step is None else step + agent_cost, benchmark_cost = self._get_cost_snapshot(session_id, session) + traj_path = session.paths.trajectory + traj_path.parent.mkdir(parents=True, exist_ok=True) + payload = json.loads(observation.model_dump_json()) if observation is not None else None + event = { + "event": "observation", + "run_id": self._run_id, + "session_id": session_id, + "session_number": session_number, + "task_id": session.task_id, + "step": step_n, + "observation": payload, + "initial": initial, + "agent_cost": agent_cost, + "benchmark_cost": benchmark_cost, + } + with open(traj_path, "a", encoding="utf-8") as f: + json.dump(event, f, ensure_ascii=False) + f.write("\n") + + def _record_session(self, session, score: SessionScore, *, agent=None) -> None: + session_id = session.session_id + with self._lock: + data = self._sessions.get(session_id) + state = self._ledger.pop_state(session_id) + execution_time = time.time() - state.started_at if state is not None else 0.0 + steps = state.steps if state is not None else 0 + action_count = data.action_count if data is not None else 0 + invalid_action_count = data.invalid_action_count if data is not None else 0 + success = bool(score.success) + value = score.score + is_finished = score.is_finished + agent_cost_report = agent.get_cost() if agent is not None else CostReport.initialize_empty() + benchmark_cost_report = session.get_cost() + status = self._resolve_session_status(score) + tr = SessionResults( + session_id=session_id, + success=success, + score=value, + is_finished=is_finished, + status=status, + steps=steps, + action_count=action_count, + invalid_action_count=invalid_action_count, + agent_cost=agent_cost_report.total_cost, + benchmark_cost=benchmark_cost_report.total_cost, + execution_time=execution_time, + details=score.model_dump(), + cost_reports={ + "agent": agent_cost_report, + "benchmark": benchmark_cost_report, + }, + task_id=session.task_id, + ) + self._pop_reason(session) + with self._lock: + self._session_results.append(tr) + if session_id in self._sessions: + del self._sessions[session_id] + sess_paths = self.paths.session(session_id) + sess_paths.results.parent.mkdir(parents=True, exist_ok=True) + with open(sess_paths.results, "w", encoding="utf-8") as f: + json.dump(tr.model_dump(), f, ensure_ascii=False, indent=2, default=str) + error_message = score.session_metadata.get("error") + if error_message: + error_source = score.session_metadata.get("error_source") + error_path = sess_paths.error_log + error_path.parent.mkdir(parents=True, exist_ok=True) + with open(error_path, "w", encoding="utf-8") as f: + if error_source: + f.write(f"source: {error_source}\n") + f.write(str(error_message)) + + def _resolve_session_status(self, score: SessionScore) -> SessionOutcomeStatus: + error_source = score.session_metadata.get("error_source") + if error_source == "cancelled": + return SessionOutcomeStatus.CANCELLED + if score.session_metadata.get("limit_reached"): + if score.is_finished is True and score.success: + return SessionOutcomeStatus.SUCCESS + return SessionOutcomeStatus.LIMIT_REACHED + if error_source in ("agent", "benchmark"): + return SessionOutcomeStatus.ERROR + if score.session_metadata.get("error"): + return SessionOutcomeStatus.ERROR + if score.is_finished is True: + return SessionOutcomeStatus.SUCCESS if score.success else SessionOutcomeStatus.UNSUCCESSFUL + if score.is_finished is False: + return SessionOutcomeStatus.UNFINISHED + return SessionOutcomeStatus.ERROR if not score.success else SessionOutcomeStatus.UNKNOWN + + def _write_run_results(self) -> RunResults: + rp = self.paths + with self._lock: + results_snapshot = list(self._session_results) + run_config = self._run_config + bench_results_obj = self._final_results if isinstance(self._final_results, BenchmarkResults) else None + if run_config is None: + raise RuntimeError("Run config not recorded in results observer.") + + # Derive current session status snapshot for provenance. + try: + status = RunStatus.from_config(run_config) + except Exception: + status = None + + completed_sessions = None + incomplete_sessions = None + missing_sessions = None + running_sessions = None + aggregated_session_ids = None + skipped_session_ids = None + skipped_session_reasons = None + missing_result_files = None + + if status is not None: + completed = [s for s in status.session_statuses if s.status == SessionExecutionStatus.COMPLETED] + incomplete = [s for s in status.session_statuses if s.status == SessionExecutionStatus.INCOMPLETE] + missing = [s for s in status.session_statuses if s.status == SessionExecutionStatus.MISSING] + running = [s for s in status.session_statuses if s.status == SessionExecutionStatus.RUNNING] + completed_sessions = len(completed) + incomplete_sessions = len(incomplete) + missing_sessions = len(missing) + running_sessions = len(running) + aggregated_session_ids = [s.session_id for s in completed] + skipped = incomplete + missing + running + skipped_session_ids = [s.session_id for s in skipped] + skipped_session_reasons = {s.session_id: str(s.status) for s in skipped} + missing_result_files = [s.results_path for s in missing] + + total_sessions = len(results_snapshot) + executed_session_ids = [r.session_id for r in results_snapshot] + planned_sessions = None + planned_session_ids = None + if run_config.task_ids: + planned_task_ids = list(run_config.task_ids) + if run_config.num_tasks is not None: + planned_task_ids = planned_task_ids[: int(run_config.num_tasks)] + planned_sessions = len(planned_task_ids) + planned_session_ids = [ + run_config.to_session_config(task_id).get_session_id() for task_id in planned_task_ids + ] + elif run_config.num_tasks is not None: + planned_sessions = int(run_config.num_tasks) + if planned_sessions is None: + planned_sessions = total_sessions + successful_sessions = sum(1 for r in results_snapshot if r.success) + percent_successful = successful_sessions / total_sessions if total_sessions else None + scores = [r.score for r in results_snapshot if r.score is not None] + average_score = (sum(scores) / len(scores)) if scores else None + + finished_successful = sum(1 for r in results_snapshot if r.is_finished is True and bool(r.success)) + finished_unsuccessful = sum(1 for r in results_snapshot if r.is_finished is True and not bool(r.success)) + unfinished = sum(1 for r in results_snapshot if r.is_finished is False) + errored = sum(1 for r in results_snapshot if r.is_finished is None) + percent_finished_successful = finished_successful / total_sessions if total_sessions else None + percent_finished_unsuccessful = finished_unsuccessful / total_sessions if total_sessions else None + percent_unfinished = unfinished / total_sessions if total_sessions else None + percent_error = errored / total_sessions if total_sessions else None + percent_finished = (finished_successful + finished_unsuccessful) / total_sessions if total_sessions else None + + total_agent_cost = sum(r.agent_cost for r in results_snapshot) if total_sessions else 0.0 + total_benchmark_cost = sum(r.benchmark_cost for r in results_snapshot) if total_sessions else 0.0 + total_run_cost = total_agent_cost + total_benchmark_cost + if results_snapshot: + agent_reports = [r.cost_reports["agent"] for r in results_snapshot] + benchmark_reports = [r.cost_reports["benchmark"] for r in results_snapshot] + try: + accumulated_agent_report = accumulate_reports(agent_reports) + except ValueError: + accumulated_agent_report = CostReport.initialize_empty() + for report in agent_reports: + accumulated_agent_report.accumulate_from(report) + try: + accumulated_benchmark_report = accumulate_reports(benchmark_reports) + except ValueError: + accumulated_benchmark_report = CostReport.initialize_empty() + for report in benchmark_reports: + accumulated_benchmark_report.accumulate_from(report) + else: + accumulated_agent_report = CostReport.initialize_empty() + accumulated_benchmark_report = CostReport.initialize_empty() + average_agent_cost = (total_agent_cost / total_sessions) if total_sessions else None + average_benchmark_cost = (total_benchmark_cost / total_sessions) if total_sessions else None + + steps = [tr.steps for tr in results_snapshot] + avg_steps = sum(steps) / len(steps) if steps else None + action_counts = [tr.action_count for tr in results_snapshot] + avg_action_count = sum(action_counts) / len(action_counts) if action_counts else None + invalid_action_counts = [tr.invalid_action_count for tr in results_snapshot] + avg_invalid_action_count = ( + sum(invalid_action_counts) / len(invalid_action_counts) if invalid_action_counts else None + ) + total_action_count = sum(action_counts) if action_counts else 0 + total_invalid_action_count = sum(invalid_action_counts) if invalid_action_counts else 0 + avg_invalid_action_percent = ( + (total_invalid_action_count / total_action_count * 100) if total_action_count else None + ) + + bench_score: Optional[float] = bench_results_obj.score if bench_results_obj is not None else None + + model_name = run_config.model or (run_config.agent_kwargs or {}).get("model") + model_names = [str(model_name)] if model_name else None + + max_workers = run_config.max_workers + if max_workers is None: + max_workers_env = os.environ.get("EXGENTIC_MAX_WORKERS") + if max_workers_env: + try: + max_workers = int(max_workers_env) + except ValueError: + max_workers = None + + bench_entry = get_benchmark_entries().get(run_config.benchmark) + agent_entry = get_agent_entries().get(run_config.agent) + bench_name = bench_entry.display_name if bench_entry is not None else run_config.benchmark + agent_name = agent_entry.display_name if agent_entry is not None else run_config.agent + + results_obj = RunResults( + benchmark_name=str(bench_name), + benchmark_slug_name=str(run_config.benchmark), + agent_name=str(agent_name), + agent_slug_name=str(run_config.agent), + model_name=str(model_name) if model_name is not None else None, + model_names=model_names, + subset_name=str(run_config.subset) if run_config.subset is not None else None, + total_sessions=total_sessions, + planned_sessions=planned_sessions, + planned_session_ids=planned_session_ids, + executed_session_ids=executed_session_ids, + max_workers=max_workers, + successful_sessions=successful_sessions, + benchmark_score=bench_score, + benchmark_results=(bench_results_obj.model_dump() if bench_results_obj is not None else None), + average_score=average_score, + average_agent_cost=average_agent_cost, + total_agent_cost=total_agent_cost, + average_benchmark_cost=average_benchmark_cost, + total_benchmark_cost=total_benchmark_cost, + total_run_cost=total_run_cost, + session_results=results_snapshot, + accumulated_agent_report=accumulated_agent_report, + accumulated_benchmark_report=accumulated_benchmark_report, + average_steps=avg_steps, + average_action_count=avg_action_count, + average_invalid_action_count=avg_invalid_action_count, + average_invalid_action_percent=avg_invalid_action_percent, + percent_finished=percent_finished, + percent_successful=percent_successful, + percent_finished_successful=percent_finished_successful, + percent_finished_unsuccessful=percent_finished_unsuccessful, + percent_unfinished=percent_unfinished, + percent_error=percent_error, + aggregation_mode="completed_only", + completed_sessions=completed_sessions, + incomplete_sessions=incomplete_sessions, + missing_sessions=missing_sessions, + running_sessions=running_sessions, + aggregated_session_ids=aggregated_session_ids, + skipped_session_ids=skipped_session_ids, + skipped_session_reasons=skipped_session_reasons, + missing_result_files=missing_result_files, + exgentic_version=exgentic_version, + ) + + try: + rp.results.parent.mkdir(parents=True, exist_ok=True) + with open(rp.results, "w", encoding="utf-8") as f: + json.dump(results_obj.model_dump(), f, ensure_ascii=False, indent=2, default=str) + if bench_results_obj is not None: + rp.benchmark_results.parent.mkdir(parents=True, exist_ok=True) + with open(rp.benchmark_results, "w", encoding="utf-8") as f: + json.dump( + bench_results_obj.model_dump(), + f, + ensure_ascii=False, + indent=2, + default=str, + ) + except OSError: + # Allow aggregation in read-only output directories. + pass + return results_obj + + def _set_reason(self, session, reason: str, *, overwrite: bool = False) -> None: + session_id = session.session_id if session else None + if session_id is None: + return + with self._lock: + data = self._sessions.get(session_id) + if data is None: + return + if data.reason is not None and not overwrite: + return + data.reason = reason + + def _pop_reason(self, session) -> str: + session_id = session.session_id if session else None + if session_id is None: + return "ended" + with self._lock: + data = self._sessions.get(session_id) + if data is None or data.reason is None: + return "ended" + reason = data.reason + data.reason = None + return reason + + def _get_cost_snapshot(self, session_id: str, session) -> tuple[float, float]: + with self._lock: + data = self._sessions.get(session_id) + agent = data.agent if data is not None else None + agent_cost_report = CostReport.initialize_empty() + benchmark_cost_report = CostReport.initialize_empty() + if agent is not None: + try: + agent_cost_report = agent.get_cost() + except Exception: + agent_cost_report = CostReport.initialize_empty() + try: + benchmark_cost_report = session.get_cost() + except Exception: + benchmark_cost_report = CostReport.initialize_empty() + return agent_cost_report.total_cost, benchmark_cost_report.total_cost + + +FileSystemObserver = ResultsObserver diff --git a/labs/AgentStream/exgentic/src/exgentic/observers/handlers/session_ledger.py b/labs/AgentStream/exgentic/src/exgentic/observers/handlers/session_ledger.py new file mode 100644 index 00000000..59d5e06c --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/observers/handlers/session_ledger.py @@ -0,0 +1,66 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import threading +import time +from dataclasses import dataclass + + +@dataclass +class SessionState: + started_at: float + steps: int = 0 + + +class SessionLedger: + def __init__(self) -> None: + self._lock = threading.Lock() + self._counter = 0 + self._numbers: dict[str, int] = {} + self._states: dict[str, SessionState] = {} + + def register(self, session_id: str) -> int: + with self._lock: + number = self._numbers.get(session_id) + if number is None: + self._counter += 1 + number = self._counter + self._numbers[session_id] = number + self._states[session_id] = SessionState(started_at=time.time()) + return number + + def mark_reuse(self, session_id: str) -> int: + with self._lock: + number = self._numbers.get(session_id) + if number is None: + self._counter += 1 + number = self._counter + self._numbers[session_id] = number + return number + + def increment_steps(self, session_id: str, count: int = 1) -> int: + with self._lock: + state = self._states.get(session_id) + if state is None: + if session_id not in self._numbers: + self._counter += 1 + self._numbers[session_id] = self._counter + state = SessionState(started_at=time.time()) + self._states[session_id] = state + state.steps += count + return state.steps + + def get_steps(self, session_id: str) -> int: + with self._lock: + state = self._states.get(session_id) + return state.steps if state is not None else 0 + + def get_number(self, session_id: str) -> int: + with self._lock: + return self._numbers.get(session_id, 0) + + def pop_state(self, session_id: str) -> SessionState | None: + with self._lock: + return self._states.pop(session_id, None) diff --git a/labs/AgentStream/exgentic/src/exgentic/observers/handlers/warnings.py b/labs/AgentStream/exgentic/src/exgentic/observers/handlers/warnings.py new file mode 100644 index 00000000..4ac96021 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/observers/handlers/warnings.py @@ -0,0 +1,27 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from ...core.context import get_context +from ...core.orchestrator.observer import Observer +from ..logging import configure_warnings_logging + + +class WarningsObserver(Observer): + def __init__(self, *, replace_existing_file_handlers: bool = True) -> None: + self._replace = replace_existing_file_handlers + self._configured = False + + def on_run_start(self, run_config) -> None: + if self._configured: + return + ctx = get_context() + try: + configure_warnings_logging( + ctx.output_dir, + ctx.run_id, + replace_existing_file_handlers=self._replace, + ) + except OSError: + # Ignore failures in read-only runs. + return + self._configured = True diff --git a/labs/AgentStream/exgentic/src/exgentic/observers/logging/__init__.py b/labs/AgentStream/exgentic/src/exgentic/observers/logging/__init__.py new file mode 100644 index 00000000..800d38a6 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/observers/logging/__init__.py @@ -0,0 +1,358 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Basic logging setup for Exgentic components.""" +import logging +import os +import sys +from pathlib import Path +from typing import Callable, Dict, List, Tuple + +from uvicorn.logging import DefaultFormatter + +from ...core.context import try_get_context +from ...core.context import try_get_context as _try_get_context_for_run_id +from ...utils.settings import get_settings + + +def _build_console_handler(*, log_level: int, formatter: logging.Formatter) -> logging.Handler: + try: + from rich.logging import RichHandler + except Exception: + handler = logging.StreamHandler(sys.stdout) + handler.setLevel(log_level) + handler.setFormatter(formatter) + return handler + + console_formatter = logging.Formatter("%(message)s") + handler = RichHandler( + show_time=False, + show_level=True, + show_path=False, + rich_tracebacks=True, + markup=False, + ) + handler.setLevel(log_level) + handler.setFormatter(console_formatter) + return handler + + +def get_logger( + name: str, + log_file_path: str | None = None, + *, + console: bool | None = None, + propagate: bool | None = None, +) -> logging.Logger: + """Get a logger with standard Exgentic configuration. + + - If `log_file_path` is provided: attach a file handler. By default, do not propagate + to avoid console duplication; set `propagate=True` if you want bubbling. + - If `log_file_path` is not provided: no console by default unless `console=True` or + `EXGENTIC_CONSOLE_LOG` is truthy. Optional default file sink via context run dir. + - Console handlers use Rich when available, falling back to a standard stream handler. + + Callers decide explicitly if they want console output by passing `console=True`. + """ + logger = logging.getLogger(name) + + if logger.handlers: + return logger + + settings = get_settings() + log_level = logging._nameToLevel.get(settings.log_level.upper(), logging.INFO) + logger.setLevel(log_level) + + formatter = logging.Formatter("%(levelname)s | %(message)s") + + if log_file_path: + # File-targeted logger + log_file = Path(log_file_path) + try: + log_file.parent.mkdir(parents=True, exist_ok=True) + fh = logging.FileHandler(log_file, encoding="utf-8") + except OSError: + # Fall back to console logging when file logging isn't writable. + ch = _build_console_handler(log_level=logging.INFO, formatter=formatter) + logger.addHandler(ch) + if propagate is None: + propagate = False + logger.propagate = propagate + return logger + fh.setLevel(logging.DEBUG) + fh.setFormatter(formatter) + logger.addHandler(fh) + + # Optional console mirroring if explicitly requested + if bool(console): + ch = _build_console_handler(log_level=logging.INFO, formatter=formatter) + logger.addHandler(ch) + + # Default: avoid bubbling to parent to prevent duplicates + if propagate is None: + propagate = False + logger.propagate = propagate + else: + # Non-file logger + enable_console_env = os.environ.get("EXGENTIC_CONSOLE_LOG", "").lower() in ( + "1", + "true", + "yes", + ) + if bool(console) or enable_console_env: + ch = _build_console_handler(log_level=logging.INFO, formatter=formatter) + logger.addHandler(ch) + + # Optional default file sink (single consolidated log) if requested + ctx = try_get_context() + if ctx is not None: + lf = Path(ctx.output_dir) / "exgentic.log" # global log, not run-scoped + try: + lf.parent.mkdir(parents=True, exist_ok=True) + fh = logging.FileHandler(lf, encoding="utf-8") + except OSError: + fh = None + if fh is not None: + fh.setLevel(logging.DEBUG) + fh.setFormatter(formatter) + logger.addHandler(fh) + + # Default propagation for non-file logger: True unless explicitly set + if propagate is not None: + logger.propagate = propagate + + return logger + + +def get_disabled_logger(name: str | None = None) -> logging.Logger: + """Return a logger that discards all messages (NullHandler, no propagation).""" + lname = name or f"{__name__}.noop" + logger = logging.getLogger(lname) + if not any(isinstance(h, logging.NullHandler) for h in logger.handlers): + logger.addHandler(logging.NullHandler()) + logger.propagate = False + logger.setLevel(logging.CRITICAL) + return logger + + +def close_logger(log: logging.Logger) -> None: + """Close and detach all file handlers from the given logger.""" + handlers_to_close = list(log.handlers) + + for handler in handlers_to_close: + if isinstance(handler, logging.FileHandler): + handler.close() + log.removeHandler(handler) + + +def configure_warnings_logging( + run_dir_base: str | None = None, + run_id: str | None = None, + *, + replace_existing_file_handlers: bool = True, +) -> str: + """Set up logging for Python warnings to a `warnings.log` file. + + - Computes `//run/warnings.log` using provided args or env/settings. + - Enables `logging.captureWarnings(True)`. + - Optionally replaces existing FileHandlers on the `py.warnings` logger. + + Returns the path to the warnings log file as a string. + """ + from ...utils.paths import RunPaths + + ctx = try_get_context() + if ctx is not None: + base = run_dir_base or ctx.output_dir + else: + settings = get_settings() + base = run_dir_base or settings.output_dir + if run_id is None: + ctx = _try_get_context_for_run_id() + rid = ctx.run_id if ctx is not None else "default" + else: + rid = run_id + warnings_path = RunPaths(run_id=rid, output_dir=base).warnings + warnings_path.parent.mkdir(parents=True, exist_ok=True) + + logging.captureWarnings(True) + wlogger = logging.getLogger("py.warnings") + + # Remove conflicting handlers per requested policy + if replace_existing_file_handlers: + for h in list(wlogger.handlers): + if isinstance(h, logging.FileHandler): + wlogger.removeHandler(h) + try: + h.close() + except Exception: + pass + else: + for h in list(wlogger.handlers): + try: + if isinstance(h, logging.FileHandler) and h.baseFilename == str(warnings_path): + wlogger.removeHandler(h) + try: + h.close() + except Exception: + pass + except Exception: + pass + + wfh = logging.FileHandler(str(warnings_path), encoding="utf-8") + wfh.setLevel(logging.WARNING) + wfh.setFormatter(logging.Formatter("%(levelname)s | %(message)s")) + wlogger.addHandler(wfh) + wlogger.setLevel(logging.WARNING) + wlogger.propagate = False + return str(warnings_path) + + +def configure_uvicorn_file_logging(log_path: Path, *, thread_id: int) -> Callable[[], None]: + handler = logging.FileHandler(log_path, encoding="utf-8") + handler.setLevel(logging.INFO) + handler.setFormatter( + DefaultFormatter( + "%(asctime)s - %(levelname)s - %(name)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) + handler.addFilter(lambda record: record.thread == thread_id) + + uvicorn_loggers = [ + logging.getLogger("uvicorn"), + logging.getLogger("uvicorn.error"), + logging.getLogger("uvicorn.access"), + ] + prev_logger_state: Dict[logging.Logger, tuple[int, bool, list[logging.Handler]]] = {} + for lg in uvicorn_loggers: + removed_handlers = list(lg.handlers) + for h in removed_handlers: + lg.removeHandler(h) + prev_logger_state[lg] = (lg.level, lg.propagate, removed_handlers) + lg.addHandler(handler) + lg.setLevel(logging.INFO) + lg.propagate = False + + def _cleanup() -> None: + for lg in uvicorn_loggers: + lg.removeHandler(handler) + prev_state = prev_logger_state.get(lg) + if prev_state is not None: + lg.setLevel(prev_state[0]) + lg.propagate = prev_state[1] + for h in prev_state[2]: + if h not in lg.handlers: + lg.addHandler(h) + handler.close() + + return _cleanup + + +def configure_library_file_logging( + log_path: Path, *, logger_names: list[str], thread_id: int | None = None +) -> Callable[[], None]: + handler = logging.FileHandler(log_path, encoding="utf-8") + handler.setLevel(logging.INFO) + handler.setFormatter( + logging.Formatter( + "%(asctime)s - %(levelname)s - %(name)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) + if thread_id is not None: + handler.addFilter(lambda record: record.thread == thread_id) + + prefixes = tuple(logger_names) + names = set(logger_names) + for name in logging.root.manager.loggerDict: + if name.startswith(prefixes): + names.add(name) + + prev_logger_state: Dict[logging.Logger, tuple[int, bool, list[logging.Handler]]] = {} + for name in names: + lg = logging.getLogger(name) + removed_handlers = list(lg.handlers) + for h in removed_handlers: + lg.removeHandler(h) + prev_logger_state[lg] = (lg.level, lg.propagate, removed_handlers) + lg.addHandler(handler) + lg.setLevel(logging.INFO) + lg.propagate = False + + def _cleanup() -> None: + for lg, (level, propagate, removed) in prev_logger_state.items(): + lg.removeHandler(handler) + lg.setLevel(level) + lg.propagate = propagate + for h in removed: + if h not in lg.handlers: + lg.addHandler(h) + handler.close() + + return _cleanup + + +def attach_library_logger_to_handler( + library_logger_name: str, + handler: logging.Handler, + *, + level: int = logging.DEBUG, + propagate: bool = False, +) -> Tuple[logging.Logger, List[logging.Handler], bool]: + """Attach a library logger to the given handler. + + Returns a tuple of (logger, previous_handlers, previous_propagate) so callers + can restore the original configuration later. + """ + logger = logging.getLogger(library_logger_name) + prev_handlers = list(logger.handlers) + prev_propagate = logger.propagate + + handler.setLevel(level) + logger.handlers = [handler] + logger.propagate = propagate + + return logger, prev_handlers, prev_propagate + + +def restore_library_logger( + logger: logging.Logger, + handlers: List[logging.Handler], + propagate: bool, +) -> None: + """Restore a library logger's handlers and propagation flag.""" + logger.handlers = handlers + logger.propagate = propagate + + +def add_loguru_file_sink(file_obj, level: str = "DEBUG", colorize: bool = False): + """Add a Loguru sink for the given file-like object. + + Returns the sink id, or None if Loguru is not available. + """ + try: + from loguru import logger as _loguru # type: ignore[import-not-found] + except Exception: + return None + + try: + return _loguru.add(file_obj, level=level, colorize=colorize) + except Exception: + return None + + +def remove_loguru_sink(sink_id) -> None: + """Remove a previously registered Loguru sink, ignoring errors.""" + if sink_id is None: + return + + try: + from loguru import logger as _loguru # type: ignore[import-not-found] + except Exception: + return + + try: + _loguru.remove(sink_id) + except Exception: + pass diff --git a/labs/AgentStream/exgentic/src/exgentic/observers/tracing/__init__.py b/labs/AgentStream/exgentic/src/exgentic/observers/tracing/__init__.py new file mode 100644 index 00000000..ed18cc54 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/observers/tracing/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +__all__ = [] diff --git a/labs/AgentStream/exgentic/src/exgentic/testing/__init__.py b/labs/AgentStream/exgentic/src/exgentic/testing/__init__.py new file mode 100644 index 00000000..d9d5fd04 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/testing/__init__.py @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Lightweight test fixtures for integration testing. + +These classes are intentionally kept inside the installed package so they +are available inside Docker containers (where ``tests.*`` is not installed). +They are *not* registered in the benchmark/agent registry by default. +""" + +from .agent import ( + BAD_ACTION_TYPE, + FINISH_ACTION_TYPE, + GOOD_ACTION_TYPE, + BadAction, + EmptyArgs, + FinishAction, + GoodAction, + TestAgent, + TestAgentInstance, +) +from .benchmark import TestBenchmark, TestEvaluator, TestSession +from .calculator import Calculator, CalculatorError +from .docker_session import DockerSession + +__all__ = [ + "BAD_ACTION_TYPE", + "BadAction", + "Calculator", + "CalculatorError", + "DockerSession", + "EmptyArgs", + "FINISH_ACTION_TYPE", + "FinishAction", + "GOOD_ACTION_TYPE", + "GoodAction", + "TestAgent", + "TestAgentInstance", + "TestBenchmark", + "TestEvaluator", + "TestSession", +] diff --git a/labs/AgentStream/exgentic/src/exgentic/testing/agent.py b/labs/AgentStream/exgentic/src/exgentic/testing/agent.py new file mode 100644 index 00000000..f9dd2ca9 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/testing/agent.py @@ -0,0 +1,145 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import hashlib +import random +from typing import Any, ClassVar, Literal + +from pydantic import BaseModel + +from ..core.agent import Agent +from ..core.agent_instance import AgentInstance +from ..core.types import Action, ActionType, SingleAction, SingleObservation + + +class EmptyArgs(BaseModel): + pass + + +class GoodAction(SingleAction): + name: Literal["good"] = "good" + arguments: EmptyArgs + + +class BadAction(SingleAction): + name: Literal["bad"] = "bad" + arguments: EmptyArgs + + +class FinishAction(SingleAction): + name: Literal["finish"] = "finish" + arguments: EmptyArgs + + +GOOD_ACTION_TYPE = ActionType( + name="good", + description="Good action", + cls=GoodAction, +) +BAD_ACTION_TYPE = ActionType( + name="bad", + description="Bad action", + cls=BadAction, +) +FINISH_ACTION_TYPE = ActionType( + name="finish", + description="Finish action", + cls=FinishAction, + is_finish=True, +) + + +class TestAgentInstance(AgentInstance): + __test__ = False + + def __init__( + self, + *, + session_id: str, + seed: int, + policy: str, + finish_after: int, + max_steps: int | None, + ) -> None: + super().__init__(session_id=session_id) + self._seed = seed + self._policy = policy + self._finish_after = finish_after + self._rng = self._build_rng(session_id, seed) + self._step = 0 + self.max_steps = max_steps + + @staticmethod + def _build_rng(session_id: str, seed: int) -> random.Random: + payload = f"{session_id}:{seed}".encode() + digest = hashlib.sha256(payload).digest() + value = int.from_bytes(digest[:4], "big") + return random.Random(value) + + def react(self, observation: SingleObservation | None) -> Action | None: + self._step += 1 + if self._policy == "return_none": + return None + if self._policy == "raise_error": + raise RuntimeError("agent failure") + if self._policy == "invalid_action": + return "not-an-action" # type: ignore[return-value] + if self._policy == "finish_immediately": + return FinishAction(arguments=EmptyArgs()) + if self._policy == "good_only": + return GoodAction(arguments=EmptyArgs()) + if self._policy == "bad_only": + return BadAction(arguments=EmptyArgs()) + if self._policy == "good_then_finish": + if self._step >= self._finish_after: + return FinishAction(arguments=EmptyArgs()) + return GoodAction(arguments=EmptyArgs()) + # random policy + choice = self._rng.choice([GOOD_ACTION_TYPE, BAD_ACTION_TYPE, FINISH_ACTION_TYPE]) + if choice is GOOD_ACTION_TYPE: + return GoodAction(arguments=EmptyArgs()) + if choice is BAD_ACTION_TYPE: + return BadAction(arguments=EmptyArgs()) + return FinishAction(arguments=EmptyArgs()) + + def close(self) -> None: + return None + + +class TestAgent(Agent): + __test__ = False + display_name: ClassVar[str] = "Test Agent" + slug_name: ClassVar[str] = "test_agent" + runner: str | None = "direct" # No external deps — run in host process + + @classmethod + def _get_instance_class(cls): + return TestAgentInstance + + seed: int = 0 + policy: Literal[ + "random", + "good_only", + "bad_only", + "good_then_finish", + "finish_immediately", + "return_none", + "invalid_action", + "raise_error", + ] = "random" + finish_after: int = 2 + max_steps: int | None = None + + def _get_instance_kwargs( + self, + session_id: str, + ) -> dict[str, Any]: + return { + "session_id": session_id, + "seed": self.seed, + "policy": self.policy, + "finish_after": self.finish_after, + "max_steps": self.max_steps, + } diff --git a/labs/AgentStream/exgentic/src/exgentic/testing/benchmark.py b/labs/AgentStream/exgentic/src/exgentic/testing/benchmark.py new file mode 100644 index 00000000..4d9dd22d --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/testing/benchmark.py @@ -0,0 +1,180 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import json +from typing import Any, ClassVar + +from ..core.benchmark import Benchmark +from ..core.evaluator import Evaluator +from ..core.session import Session +from ..core.types import ( + Action, + ActionType, + BenchmarkResults, + SessionIndex, + SessionScore, + SingleObservation, +) +from .agent import ( + BAD_ACTION_TYPE, + FINISH_ACTION_TYPE, + GOOD_ACTION_TYPE, + BadAction, + FinishAction, + GoodAction, +) + + +class TestSession(Session): + __test__ = False + + def __init__( + self, + *, + task_id: str, + session_id: str, + stop_on_step: bool, + invalid_observation: bool, + ) -> None: + self._session_id = session_id + self._task_id = task_id + self._stop_on_step = stop_on_step + self._invalid_observation = invalid_observation + self._done = False + self._good = 0 + self._bad = 0 + self._steps = 0 + super().__init__() + + @property + def task_id(self) -> str: + return self._task_id + + @property + def task(self) -> str: + return f"Task {self._task_id}" + + @property + def context(self) -> dict[str, Any]: + return {"task_id": self._task_id} + + @property + def actions(self) -> list[ActionType]: + return [GOOD_ACTION_TYPE, BAD_ACTION_TYPE, FINISH_ACTION_TYPE] + + def start(self) -> SingleObservation: + return SingleObservation(result="start") + + def step(self, action: Action) -> SingleObservation | None: + self._steps += 1 + if self._invalid_observation: + return "invalid-observation" # type: ignore[return-value] + if isinstance(action, GoodAction): + self._good += 1 + elif isinstance(action, BadAction): + self._bad += 1 + elif isinstance(action, FinishAction): + self._done = True + return SingleObservation(result="finish") + if self._stop_on_step: + return None + return SingleObservation(result="step") + + def done(self) -> bool: + return self._done + + def score(self) -> SessionScore: + total = self._good + self._bad + score = float(self._good / total) if total > 0 else 0.0 + success = bool(self._done and self._bad == 0 and total > 0) + result = SessionScore( + score=score, + success=success, + is_finished=self._done, + session_metrics={"good": self._good, "bad": self._bad, "total": total}, + session_metadata={"steps": self._steps}, + ) + self.save_standard_results(result) + return result + + def get_config(self) -> dict[str, Any]: + return { + "task_id": self._task_id, + "stop_on_step": self._stop_on_step, + } + + def close(self) -> None: + return None + + +class TestEvaluator(Evaluator): + __test__ = False + + def __init__( + self, + *, + tasks: list[str] | None = None, + stop_on_step: bool = False, + invalid_observation: bool = False, + ) -> None: + self._tasks = tasks or ["task-1", "task-2", "task-3"] + self._stop_on_step = stop_on_step + self._invalid_observation = invalid_observation + + def list_tasks(self) -> list[str]: + return list(self._tasks) + + def get_session_kwargs(self, index: SessionIndex) -> dict[str, Any]: + return { + "task_id": str(index.task_id), + "session_id": index.session_id, + "stop_on_step": self._stop_on_step, + "invalid_observation": self._invalid_observation, + } + + def aggregate_sessions(self, sessions: list[SessionIndex]) -> BenchmarkResults: + scores: list[float] = [] + for paths in self.get_sessions_paths(sessions): + if not paths.results.exists(): + continue + payload = json.loads(paths.results.read_text(encoding="utf-8")) + try: + score = float(payload["score"]) + except Exception: + continue + scores.append(score) + avg = sum(scores) / len(scores) if scores else 0.0 + return BenchmarkResults( + benchmark_name="test_benchmark", + total_tasks=len(sessions), + score=avg, + metrics={}, + ) + + +class TestBenchmark(Benchmark): + __test__ = False + display_name: ClassVar[str] = "Test Benchmark" + slug_name: ClassVar[str] = "test_benchmark" + runner: str | None = "direct" # Run in-process; override via benchmark_kwargs for runner tests + tasks: list[str] = ["task-1", "task-2", "task-3"] # noqa: RUF012 + + @classmethod + def _get_evaluator_class(cls): + return TestEvaluator + + @classmethod + def _get_session_class(cls): + return TestSession + + stop_on_step: bool = False + invalid_observation: bool = False + + def _get_evaluator_kwargs(self) -> dict[str, Any]: + return { + "tasks": self.tasks, + "stop_on_step": self.stop_on_step, + "invalid_observation": self.invalid_observation, + } diff --git a/labs/AgentStream/exgentic/src/exgentic/testing/calculator.py b/labs/AgentStream/exgentic/src/exgentic/testing/calculator.py new file mode 100644 index 00000000..4a8c1b3b --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/testing/calculator.py @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Dummy Calculator class for runner/transport tests. + +Lives inside the installed package so it is importable inside Docker +containers (where ``tests.*`` is not installed). +""" + +from __future__ import annotations + +import os +import threading + + +class CalculatorError(Exception): + """Custom exception for testing cross-transport error propagation.""" + + def __init__(self, message: str, code: int = 0) -> None: + super().__init__(message) + self.code = code + + +class Calculator: + """Dummy target for transport tests.""" + + def __init__(self, value: int = 0) -> None: + self.value = value + + def add(self, a: int, b: int) -> int: + return a + b + + def accumulate(self, n: int) -> int: + self.value += n + return self.value + + def divide(self, a: int, b: int) -> float: + return a / b + + def fail_custom(self) -> None: + raise CalculatorError("something went wrong", code=42) + + def thread_id(self) -> int: + return threading.get_ident() + + def pid(self) -> int: + return os.getpid() + + def echo(self, obj: object) -> object: + return obj diff --git a/labs/AgentStream/exgentic/src/exgentic/testing/docker_session.py b/labs/AgentStream/exgentic/src/exgentic/testing/docker_session.py new file mode 100644 index 00000000..ce17b442 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/testing/docker_session.py @@ -0,0 +1,68 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Minimal session-like object for Docker e2e tests. + +Not a real Session subclass — avoids pulling in Session.__init__ +which writes files. We only need the method/property interface that +the ObjectProxy will forward over HTTP. +""" + +from __future__ import annotations + +import os + + +class DockerSession: + """Minimal session-like object for Docker e2e tests.""" + + def __init__(self, task_id: str, output_dir: str | None = None) -> None: + self._task_id = task_id + self._done = False + self._good = 0 + self._steps = 0 + self._output_dir = output_dir + + @property + def task_id(self) -> str: + return self._task_id + + @property + def task(self) -> str: + return f"Task {self._task_id}" + + @property + def context(self) -> dict: + return {"task_id": self._task_id} + + def start(self) -> dict: + return {"result": "start"} + + def step(self, action_name: str) -> dict: + self._steps += 1 + if action_name == "good": + self._good += 1 + return {"result": "step"} + if action_name == "finish": + self._done = True + return {"result": "finish"} + return {"result": "step"} + + def done(self) -> bool: + return self._done + + def score(self) -> dict: + total = self._good + return {"score": 1.0 if total > 0 else 0.0, "success": self._done and total > 0} + + def write_output(self, filename: str, content: str) -> str: + """Write a file to the output dir. Used to verify volume mounts.""" + out = self._output_dir or os.environ.get("EXGENTIC_OUTPUT_DIR", "/tmp") + path = os.path.join(out, filename) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + f.write(content) + return path + + def close(self) -> None: + pass diff --git a/labs/AgentStream/exgentic/src/exgentic/utils/__init__.py b/labs/AgentStream/exgentic/src/exgentic/utils/__init__.py new file mode 100644 index 00000000..367fec41 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/utils/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. diff --git a/labs/AgentStream/exgentic/src/exgentic/utils/cost.py b/labs/AgentStream/exgentic/src/exgentic/utils/cost.py new file mode 100644 index 00000000..2b01ead8 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/utils/cost.py @@ -0,0 +1,222 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from typing import Any, Optional, Self, Sequence, TypeVar + +from pydantic import BaseModel, computed_field + +name_map = {"claude-3-5-haiku": "claude-3-5-haiku-20241022"} + + +class TokensCost(BaseModel): + input_cost: float + output_cost: float + total_cost: float + + +def _cost_per_token(*, model: str, prompt_tokens: int, completion_tokens: int): + from litellm.cost_calculator import cost_per_token + + return cost_per_token(model=model, prompt_tokens=prompt_tokens, completion_tokens=completion_tokens) + + +def litellm_cost_per_token(model_name: str): + return _cost_per_token(model=model_name, prompt_tokens=1, completion_tokens=1) + + +def litellm_tokens_cost(input_tokens: int, output_tokens: int, model_name: str) -> TokensCost: + for src, dst in name_map.items(): + model_name = model_name.replace(src, dst) + + parts = model_name.lower().split("/") + for i in range(len(parts)): + model = "/".join(parts[-i:]) + try: + input_cost, output_cost = _cost_per_token( + model=model, prompt_tokens=input_tokens, completion_tokens=output_tokens + ) + return TokensCost( + input_cost=input_cost, + output_cost=output_cost, + total_cost=input_cost + output_cost, + ) + except Exception: + input_cost, output_cost, total_cost = None, None, None + continue + return TokensCost( + input_cost=input_cost or 0.0, + output_cost=output_cost or 0.0, + total_cost=total_cost or 0.0, + ) + + +class CostReport(BaseModel): + _total_cost: float = 0 + model_name: str = "" + + @classmethod + def initialize_empty(cls, model_name: str = "") -> Self: + """Create an empty cost report with zero costs.""" + return cls(model_name=model_name) + + @computed_field + @property + def total_cost(self) -> float: + return self._total_cost + + def accumulate_from(self, other: Self) -> None: + # Accumulate by total_cost only + self._total_cost += float(other.total_cost) + + +class UpdatableCostReport(CostReport): + """A simple accumulator that supports adding arbitrary cost amounts.""" + + def add_cost(self, new_cost: float) -> None: + self._total_cost += new_cost + + +class LLMCostReport(CostReport): + """Represents a cost report for LLM usage. + + Usage: + # Explicit definition + report = CostReport(model_name="gpt-4", input_tokens=100, output_tokens=50, input_cost=0.02, output_cost=0.03) + + # Empty report + empty_report = CostReport.initialize_empty("gpt-4") + """ + + input_tokens: int + output_tokens: int + input_cost: float + output_cost: float + + @classmethod + def initialize_empty(cls, model_name: str = "") -> Self: + """Create an empty cost report with zero tokens and costs.""" + return cls( + model_name=model_name, + input_tokens=0, + output_tokens=0, + input_cost=0, + output_cost=0, + ) + + @computed_field + @property + def total_cost(self) -> float: + return self.input_cost + self.output_cost + + @computed_field + @property + def total_tokens(self) -> float: + return self.input_tokens + self.output_tokens + + def update_cost(self, input_tokens, output_tokens, input_cost, output_cost) -> None: + """Update the report with additional tokens and costs.""" + self.input_tokens += input_tokens + self.output_tokens += output_tokens + self.input_cost += input_cost + self.output_cost += output_cost + + def accumulate_from(self, other: Self) -> None: + """Accumulate costs and tokens from other.""" + self.input_tokens += int(other.input_tokens) + self.output_tokens += int(other.output_tokens) + self.input_cost += float(other.input_cost) + self.output_cost += float(other.output_cost) + + +class LiteLLMCostReport(LLMCostReport): + """Specialized cost report that calculates costs using LiteLLM pricing. + + Additional Features: + - Auto-calculates cost if not provided. + - Provides helper methods to update cost from token counts. + + Usage: + report = LiteLLMCostReport.from_token_counts("gpt-4", 100, 50) + report.update_cost_from_tokens(20, 10) + """ + + output_cost: Optional[float] = None + input_cost: Optional[float] = None + + def model_post_init(self, __context: Any) -> None: + if self.output_cost is None or self.input_cost is None: + cost_data = LiteLLMCostReport.get_litellm_tokens_cost( + self.input_tokens, self.output_tokens, model_name=self.model_name + ) + self.output_cost = cost_data.output_cost + self.input_cost = cost_data.input_cost + + @classmethod + def from_token_counts(cls, model_name, input_tokens, output_tokens) -> "LiteLLMCostReport": + """Create a report from token counts, auto-calculating costs.""" + cost_data = cls.get_litellm_tokens_cost(input_tokens, output_tokens, model_name=model_name) + return cls( + model_name=model_name, + input_tokens=input_tokens, + output_tokens=output_tokens, + input_cost=cost_data.input_cost, + output_cost=cost_data.output_cost, + ) + + def update_cost_from_tokens(self, new_input_tokens, new_output_tokens): + """Update cost based on new token counts.""" + new_cost_data = LiteLLMCostReport.get_litellm_tokens_cost(new_input_tokens, new_output_tokens, self.model_name) + self.update_cost( + new_input_tokens, + new_output_tokens, + new_cost_data.input_cost, + new_cost_data.output_cost, + ) + + @classmethod + def get_litellm_tokens_cost(cls, input_tokens, output_tokens, model_name) -> TokensCost: + """Fetch cost data from LiteLLM pricing API.""" + if input_tokens == 0 and output_tokens == 0: + return TokensCost(input_cost=0, output_cost=0, total_cost=0) + return litellm_tokens_cost(input_tokens, output_tokens, model_name=model_name) + + +T = TypeVar("T", bound=CostReport) + + +def accumulate_reports(reports: Sequence[T]) -> T: + """Accumulate a sequence of same-typed cost reports into a single report of the same type. + + Behavior: + - Enforces that all items are of the same concrete type. + - Preserves `model_name` if identical across items; else uses 'mixed'. + - Uses the report's own `accumulate_from` implementation to merge. + + Args: + reports: non-empty sequence of reports, all of the same type. + + Returns: + A new report of the same type, containing the accumulated data. + + Raises: + ValueError: if the list is empty or contains mixed types. + """ + if not reports: + raise ValueError("The reports list cannot be empty.") + + first = reports[0] + first_type = type(first) + + if not all(type(r) is first_type for r in reports): + raise ValueError("All reports must be of the same concrete type.") + + # Preserve model_name if consistent; otherwise use 'mixed' + first_model_name = first.model_name + same_model_name = all(r.model_name == first_model_name for r in reports) + acc_report = first_type.initialize_empty(model_name=first_model_name if same_model_name else "mixed") + + # Fold via the type's own accumulation logic + for r in reports: + acc_report.accumulate_from(r) + + return acc_report diff --git a/labs/AgentStream/exgentic/src/exgentic/utils/disk_cache.py b/labs/AgentStream/exgentic/src/exgentic/utils/disk_cache.py new file mode 100644 index 00000000..07754d52 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/utils/disk_cache.py @@ -0,0 +1,168 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import json +import threading +from pathlib import Path +from typing import Any, ClassVar, Optional + +from diskcache import Cache, JSONDisk + + +class DiskCacheSessionMixin: + """Mixin that provides disk-backed caching for benchmark sessions. + + Subclasses should set `CACHE_DIR` to control the cache location and + implement the abstract hooks: + - build_cache_key_payload() -> Dict[str, Any] + - on_cache_hit(payload) -> bool + - prepare_cache_payload(score) -> Dict[str, Any] + """ + + CACHE_DIR: ClassVar[str] = "./exgentic_session_cache" + _cache_local: ClassVar[threading.local] = threading.local() + logger: Any | None = None + use_cache: bool = True + _cache_hit: bool = False + _cache_payload: Optional[dict[str, Any]] = None + _cached_score: Optional[dict[str, Any]] = None + _results_payload: Optional[dict[str, Any]] = None + + def _init_cache_mixin(self, use_cache: bool = True) -> None: + self.use_cache = use_cache + self._cache_hit = False + self._cache_payload: Optional[dict[str, Any]] = None + self._cached_score: Optional[dict[str, Any]] = None + self._results_payload: Optional[dict[str, Any]] = None + + # --- Logging helpers ------------------------------------------------- + def _log_debug(self, message: str) -> None: + if self.logger: + self.logger.debug(message) + + def _log_info(self, message: str) -> None: + if self.logger: + self.logger.info(message) + + def _log_warning(self, message: str) -> None: + if self.logger: + self.logger.warning(message) + + # --- Cache internals ------------------------------------------------- + @classmethod + def _cache(cls) -> Cache: + # Use thread-local storage so each thread gets its own Cache instance. + # diskcache supports concurrent access from separate Cache objects but + # a single Cache (wrapping one SQLite connection) cannot be shared + # across threads. + attr = f"_cache_{cls.__name__}" + cache = getattr(cls._cache_local, attr, None) + if cache is None: + Path(cls.CACHE_DIR).mkdir(parents=True, exist_ok=True) + cache = Cache(cls.CACHE_DIR, disk=JSONDisk) + setattr(cls._cache_local, attr, cache) + return cache + + def build_cache_key_payload(self) -> dict[str, Any]: + raise NotImplementedError + + def build_cache_key(self) -> Optional[str]: + try: + payload = self.build_cache_key_payload() + return json.dumps(payload, sort_keys=True, default=str) + except TypeError as exc: + self._log_warning(f"Failed to serialize cache key payload: {exc}") + return None + + def on_cache_hit(self, payload: dict[str, Any]) -> bool: + """Hook invoked when cache payload is loaded. Return False to ignore.""" + return True + + def prepare_cache_payload(self, score: dict[str, Any]) -> dict[str, Any]: + """Hook used to create payload to store in cache.""" + payload: dict[str, Any] = {"score": score} + results_payload = self.get_results_payload() + if results_payload is not None: + payload["results"] = results_payload + metadata = self.build_additional_cache_metadata() + if metadata: + payload["metadata"] = metadata + return payload + + def build_additional_cache_metadata(self) -> dict[str, Any]: + """Hook for subclasses to include extra metadata in the cache entry.""" + return {} + + # --- Public helpers -------------------------------------------------- + @property + def cache_hit(self) -> bool: + return self._cache_hit + + @property + def cache_payload(self) -> Optional[dict[str, Any]]: + return self._cache_payload + + @property + def cached_score(self) -> Optional[dict[str, Any]]: + return self._cached_score + + def set_results_payload(self, payload: Optional[dict[str, Any]]) -> None: + self._results_payload = payload + + def get_results_payload(self) -> Optional[dict[str, Any]]: + return self._results_payload + + def handle_cache_start(self) -> bool: + return self.maybe_load_from_cache() + + def maybe_load_from_cache(self) -> bool: + if not self.use_cache: + return False + cache_key = self.build_cache_key() + if not cache_key: + return False + payload = self._cache().get(cache_key) + if payload is None: + self._log_debug("No cached entry for session.") + return False + if not self.on_cache_hit(payload): + self._log_warning("Cache payload rejected by session hook.") + return False + self._set_cached_score(payload.get("score")) + self.set_results_payload(payload.get("results")) + self._cache_hit = True + self._cache_payload = payload + self._log_info(f"Reusing session results found in disk cache at {self.CACHE_DIR}") + return True + + def write_cache_entry(self, payload: dict[str, Any]) -> None: + if not self.use_cache: + return + cache_key = self.build_cache_key() + if not cache_key: + return + try: + self._cache()[cache_key] = payload + self._cache_payload = payload + self._log_info(f"Saving session results to cache at {self.CACHE_DIR}") + except Exception as exc: # pragma: no cover - defensive + self._log_warning(f"Failed to write cache entry: {exc}") + + def cache_score(self, score: dict[str, Any]) -> None: + self._set_cached_score(score) + payload = self.prepare_cache_payload(score) + self.write_cache_entry(payload) + + def restore_cache_payload(self) -> None: + payload = self.cache_payload + if payload: + self.on_cache_restore(payload) + + def on_cache_restore(self, payload: dict[str, Any]) -> None: + """Optional hook to restore artifacts from cache.""" + return + + def _set_cached_score(self, score: Optional[dict[str, Any]]) -> None: + self._cached_score = score diff --git a/labs/AgentStream/exgentic/src/exgentic/utils/logging.py b/labs/AgentStream/exgentic/src/exgentic/utils/logging.py new file mode 100644 index 00000000..4b3b7fd8 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/utils/logging.py @@ -0,0 +1,87 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +# exgentic/benchmarks/swebench/util.py +import logging +import sys +from contextlib import contextmanager + + +class SessionLogHandler(logging.Handler): + """Forward logs from other loggers into an Exgentic session logger, with source label.""" + + def __init__(self, session_logger: logging.Logger): + super().__init__() + self.session_logger = session_logger + + def emit(self, record: logging.LogRecord) -> None: + msg = self.format(record) + source = record.name or "logger" + self.session_logger.log(record.levelno, f"[{source}] {msg}") + + +def hook_loggers_into_session( + session_logger: logging.Logger, + logger_names: list[str], + level: int = logging.INFO, +) -> None: + """Redirect logs from the given logger names into session_logger. + + Each line will be prefixed with [logger_name]. + """ + handler = SessionLogHandler(session_logger) + # You can add a formatter if you want timestamps inside msg + # handler.setFormatter(logging.Formatter("%(levelname)s - %(message)s")) + + for name in logger_names: + lg = logging.getLogger(name) + lg.handlers.clear() # prevent duplicate stdout noise + lg.addHandler(handler) + lg.setLevel(level) + lg.propagate = False + + +class StreamToLogger: + """File-like object that sends writes to session_logger with a given source label.""" + + def __init__(self, session_logger: logging.Logger, source: str, level: int): + self.logger = session_logger + self.source = source + self.level = level + self._buf = "" + + def write(self, message: str) -> None: + if not message: + return + self._buf += message + while "\n" in self._buf: + line, self._buf = self._buf.split("\n", 1) + line = line.rstrip("\r") + if line.strip(): + self.logger.log(self.level, f"[{self.source}] {line}") + + def flush(self) -> None: + if self._buf.strip(): + self.logger.log(self.level, f"[{self.source}] {self._buf.strip()}") + self._buf = "" + + +@contextmanager +def capture_stdio_to_session( + session_logger: logging.Logger, + stdout_level: int = logging.INFO, + stderr_level: int = logging.WARNING, +): + """Redirect sys.stdout and sys.stderr into session_logger. + + Lines from stdout are prefixed with [stdout], from stderr with [stderr]. + """ + old_stdout, old_stderr = sys.stdout, sys.stderr + sys.stdout = StreamToLogger(session_logger, "stdout", stdout_level) + sys.stderr = StreamToLogger(session_logger, "stderr", stderr_level) + try: + yield + finally: + sys.stdout.flush() + sys.stderr.flush() + sys.stdout, sys.stderr = old_stdout, old_stderr diff --git a/labs/AgentStream/exgentic/src/exgentic/utils/otel.py b/labs/AgentStream/exgentic/src/exgentic/utils/otel.py new file mode 100644 index 00000000..f0cb6834 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/utils/otel.py @@ -0,0 +1,532 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Utility functions for OpenTelemetry initialization and logging. + +This module provides OTEL setup functions and structured logging for OTEL operations. +""" + +import base64 +import json +import os +from datetime import date, datetime +from decimal import Decimal +from pathlib import Path, PurePath +from typing import Any, Dict, Mapping, Optional, Sequence, Union + +from opentelemetry import trace +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter as GrpcOTLPSpanExporter, +) +from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( + OTLPSpanExporter as HttpOTLPSpanExporter, +) +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor, SimpleSpanProcessor +from opentelemetry.sdk.trace.id_generator import IdGenerator +from opentelemetry.trace import Tracer +from opentelemetry.util.types import AttributeValue + +OTEL_SPAN_ATTRIBUTE_NAMESPACE = "exgentic" + + +class UrandomIdGenerator(IdGenerator): + """ID generator using os.urandom for unique random IDs.""" + + def generate_span_id(self) -> int: + return int.from_bytes(os.urandom(8), "big") + + def generate_trace_id(self) -> int: + return int.from_bytes(os.urandom(16), "big") + + +def init_tracing_from_env( + service_name: Optional[str] = None, + use_urandom_ids: bool = True, + use_simple_processor: bool = True, +) -> Tracer: + """Initialize OpenTelemetry tracing from environment variables. + + This function is idempotent - if a TracerProvider is already set, it will not + reinitialize and will just return a tracer. + + Args: + service_name: Optional service name override. Defaults to OTEL_SERVICE_NAME env var or "exgentic". + use_urandom_ids: Whether to use UrandomIdGenerator for span/trace IDs. Default True. + use_simple_processor: If True, use SimpleSpanProcessor (immediate export) instead of BatchSpanProcessor. + Useful for subprocesses that may exit before batch export completes. + + Returns: + A Tracer instance. + + Env vars honored by the SDK / exporters include (non-exhaustive): + - OTEL_SERVICE_NAME + - OTEL_SERVICE_VERSION + - OTEL_RESOURCE_ATTRIBUTES + - OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG + - OTEL_EXPORTER_OTLP_{ENDPOINT,HEADERS,PROTOCOL} + - OTEL_EXPORTER_OTLP_TRACES_{ENDPOINT,HEADERS,PROTOCOL,COMPRESSION,TIMEOUT} + """ + # Check if tracer provider is already set + current_provider = trace.get_tracer_provider() + # ProxyTracerProvider means no real provider is set + if type(current_provider).__name__ != "ProxyTracerProvider": + # Already initialized, just return a tracer + return trace.get_tracer(__name__) + + # Initialize the tracer provider + resource = Resource.create( + { + "service.name": service_name or os.getenv("OTEL_SERVICE_NAME", "exgentic"), + "service.version": os.getenv("OTEL_SERVICE_VERSION", "1.0.0"), + "service.namespace": os.getenv("OTEL_SERVICE_NAMESPACE", "exgentic"), + "deployment.environment.name": os.getenv("DEPLOYMENT_ENVIRONMENT", "dev"), + } + ) + + id_generator = UrandomIdGenerator() if use_urandom_ids else None + provider = TracerProvider(resource=resource, id_generator=id_generator) + trace.set_tracer_provider(provider) + + protocol = os.getenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf").strip().lower() + exporter = GrpcOTLPSpanExporter() if protocol == "grpc" else HttpOTLPSpanExporter() + + # Use SimpleSpanProcessor for immediate export (useful for subprocesses) + # or BatchSpanProcessor for better performance (default) + if use_simple_processor: + provider.add_span_processor(SimpleSpanProcessor(exporter)) + else: + provider.add_span_processor(BatchSpanProcessor(exporter)) + + return trace.get_tracer(__name__) + + +def check_otel_collector_health(timeout: int = 5) -> tuple[bool, Optional[str]]: + """Check if the OTEL collector endpoint is reachable and protocol matches. + + Verifies: + 1. Endpoint is reachable (socket connection) + 2. Protocol (HTTP/gRPC) matches what the server supports + + Args: + timeout: Connection timeout in seconds (default: 5) + + Returns: + Tuple of (is_healthy, error_message) + - (True, None) if collector is reachable and protocol matches + - (False, error_message) if collector is not reachable or protocol mismatch + """ + import socket + from urllib.parse import urlparse + + # Get endpoint and protocol from environment + protocol = os.getenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf").strip().lower() + + # Try traces-specific endpoint first, fall back to general endpoint + endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT") + + if not endpoint: + return False, "OTEL_EXPORTER_OTLP_ENDPOINT not specified" + + host = "Unknown" + port = 0 + try: + parsed = urlparse(endpoint) + host = parsed.hostname + port = parsed.port + + if not host or not port: + return False, f"Invalid endpoint URL: {endpoint}" + + # Step 1: Check if endpoint is reachable via socket + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + result = sock.connect_ex((host, port)) + sock.close() + + if result != 0: + return False, f"Cannot connect to OTEL collector at {host}:{port}" + + # Step 2: Verify protocol matches by attempting a minimal request + if protocol.startswith("http"): + # For HTTP protocol, try a simple HTTP request to verify it's an HTTP server + try: + import http.client + + # Determine if we should use HTTPS + use_https = parsed.scheme == "https" + conn_class = http.client.HTTPSConnection if use_https else http.client.HTTPConnection + + conn = conn_class(host, port, timeout=timeout) + # Try to access the OTLP traces endpoint + conn.request("POST", "/v1/traces", headers={"Content-Type": "application/x-protobuf"}) + response = conn.getresponse() + conn.close() + + # We expect either 200 (OK), 400 (bad request - empty body), or 405 (method not allowed) + # What we DON'T want is connection refused or protocol errors + if response.status in (200, 400, 405, 415): # 415 = Unsupported Media Type + return True, None + return False, f"HTTP endpoint responded with unexpected status: {response.status}" + + except http.client.HTTPException as e: + return False, f"HTTP protocol error: {e!s}. Server {endpoint} may not support HTTP protocol." + except Exception as e: + # If we can connect via socket but HTTP fails, likely a protocol mismatch + return False, f"Protocol mismatch: configured as HTTP but server {endpoint} may be gRPC. Error: {e!s}" + + elif protocol == "grpc": + # For gRPC, attempt a basic protocol check + # We'll try to send a minimal gRPC frame to verify it's a gRPC server + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + sock.connect((host, port)) + + # Send HTTP/2 connection preface followed by SETTINGS frame + # This is what a real gRPC client sends + preface = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" + # HTTP/2 SETTINGS frame: length=0, type=4, flags=0, stream_id=0 + settings_frame = b"\x00\x00\x00\x04\x00\x00\x00\x00\x00" + sock.sendall(preface + settings_frame) + + # Try to receive a response + # A gRPC/HTTP2 server MUST respond with a SETTINGS frame + sock.settimeout(2) # Short timeout for response + response = sock.recv(1024) + sock.close() + + # Validate we got a proper HTTP/2 response + # HTTP/2 frames start with 3-byte length, 1-byte type, 1-byte flags, 4-byte stream ID + if len(response) >= 9: + # Check if we got a SETTINGS frame (type=4) or other valid HTTP/2 frame + frame_type = response[3] + if frame_type in (0x04, 0x00, 0x01): # SETTINGS, DATA, or HEADERS frame + return True, None + + # If we got a response but it's not HTTP/2, it's likely HTTP/1.1 + if response: + if response.startswith(b"HTTP/1"): + return ( + False, + f"Server at {endpoint} is HTTP/1.1, not gRPC. Use 'http/protobuf' protocol instead.", + ) + return ( + False, + f"Server at {endpoint} responded but not with valid HTTP/2 frames. May not be a gRPC server.", + ) + + return False, f"gRPC endpoint at {endpoint} did not respond to HTTP/2 preface" + + except socket.timeout: + return ( + False, + f"Timeout waiting for gRPC response from {endpoint}. Server may not support gRPC protocol.", + ) + except Exception as e: + return False, f"gRPC protocol check failed: {e!s}. Server {endpoint} may not support gRPC protocol." + + else: + return False, f"Unknown protocol: {protocol}. Expected 'http/protobuf' or 'grpc'" + + except socket.gaierror: + return False, f"Cannot resolve hostname: {host}" + except socket.timeout: + return False, f"Connection timeout to {host}:{port}" + except Exception as e: + return False, f"Error checking OTEL collector: {e!s}" + + +def flush_traces(timeout_millis: int = 30000) -> bool: + """Flush all pending spans to the configured exporter. + + This ensures that all spans are exported before the process exits or + when you want to guarantee delivery at a specific point (e.g., end of session). + + Args: + timeout_millis: Maximum time to wait for flush to complete, in milliseconds. + Default is 30000 (30 seconds). + + Returns: + True if flush succeeded, False otherwise. + """ + try: + provider = trace.get_tracer_provider() + # Check if we have a real TracerProvider (not ProxyTracerProvider) + if type(provider).__name__ == "ProxyTracerProvider": + return True # No real provider, nothing to flush + return provider.force_flush(timeout_millis) + except Exception: + return False + + +_Primitive = (str, bool, int, float) + + +def _to_primitive_number(x: Any) -> Optional[Union[int, float]]: + """Convert numeric-ish types to plain Python int/float.""" + if isinstance(x, bool): + # bool is a subclass of int; do not coerce. + return None + if isinstance(x, (int, float)): + return x + if isinstance(x, Decimal): + # Prefer float for AttributeValue; fall back to string elsewhere if needed. + return float(x) + return None + + +def _canonical_attr_type(x: Any) -> Optional[type]: + """Return which primitive type x maps to, or None if not primitive.""" + if isinstance(x, bool): + return bool + if isinstance(x, int) and not isinstance(x, bool): + return int + if isinstance(x, float): + return float + if isinstance(x, str): + return str + return None + + +def _json_default(o: Any) -> Any: + """Safe fallback for json.dumps(default=...).""" + # Pydantic v2 + if hasattr(o, "model_dump"): + try: + return o.model_dump() + except Exception: + pass + # Pydantic v1 + if hasattr(o, "dict"): + try: + return o.dict() + except Exception: + pass + # dataclasses + try: + from dataclasses import asdict, is_dataclass + + if is_dataclass(o): + return asdict(o) + except Exception: + pass + # Datetime/Date + if isinstance(o, (datetime, date)): + return o.isoformat() + # Paths + if isinstance(o, (PurePath,)): + return str(o) + # Bytes-like → base64 wrapper so it round-trips + if isinstance(o, (bytes, bytearray, memoryview)): + return {"__bytes_b64__": base64.b64encode(bytes(o)).decode("ascii")} + # Fallback: repr as string + return str(o) + + +def _to_homogeneous_sequence(seq: Sequence[Any]) -> Optional[Sequence[Union[str, bool, int, float]]]: + """Try to coerce a sequence into a homogeneous list of primitives allowed by AttributeValue. + + Returns list on success, None on failure. + """ + arr = list(seq) + + primed = [] + for v in arr: + if v is None: + # None in arrays is not portably supported; bail to JSON outside. [2](https://opentelemetry.io/docs/specs/otel/common/) + return None + if isinstance(v, _Primitive): + primed.append(v) + continue + # Try numeric coercion (Decimal) + num = _to_primitive_number(v) + if num is not None: + primed.append(num) + continue + # Datetimes/paths/bytes -> string + if isinstance(v, (datetime, date, PurePath, bytes, bytearray, memoryview)): + primed.append(str(_json_default(v))) + continue + # Not representable as primitive + return None + + # Check homogeneity (bool must not mix with ints) + types = {_canonical_attr_type(x) for x in primed} + if None in types: + return None + if len(types) == 1: + return primed + # Allow implicit upcast to float when mixing int/float + if types == {int, float}: + return [float(x) for x in primed] + # Mixed types like str+int or bool+int are not allowed for attribute arrays. [2](https://opentelemetry.io/docs/specs/otel/common/) + return None + + +def to_otel_attribute_value(value: Any, *, prefer_json: bool = True) -> Optional[AttributeValue]: + """Convert an arbitrary value into an OpenTelemetry-Python AttributeValue for spans. + + Returns: + - A valid AttributeValue (str|bool|int|float|homogeneous Sequence thereof) on success. + - None if the attribute should be skipped (e.g., value is None). + """ + # 1) None: skip (undefined/strongly discouraged). [3](https://opentelemetry-python.readthedocs.io/en/latest/api/trace.span.html) + if value is None: + return None + + # 2) Accepted primitives + if isinstance(value, _Primitive): + return bool(value) if isinstance(value, bool) else value # type: ignore[return-value] + + # 3) Numeric-like -> int/float + num = _to_primitive_number(value) + if num is not None: + return num # type: ignore[return-value] + + # 4) datetime/date -> ISO string; Path -> str; bytes -> base64 string + if isinstance(value, (datetime, date)): + return value.isoformat() + if isinstance(value, (PurePath,)): + return str(value) + if isinstance(value, (bytes, bytearray, memoryview)): + return base64.b64encode(bytes(value)).decode("ascii") + + # 5) Sequences -> attempt homogeneous primitive array; else JSON + from collections.abc import Sequence as _Seq + + if isinstance(value, _Seq) and not isinstance(value, (str, bytes, bytearray, memoryview)): + coerced = _to_homogeneous_sequence(value) + if coerced is not None: + return coerced # type: ignore[return-value] + if prefer_json: + try: + return json.dumps(value, default=_json_default, ensure_ascii=False, sort_keys=True) + except Exception: + return str(value) + + # 6) Mappings/objects -> JSON string + if isinstance(value, Mapping) or hasattr(value, "__dict__") or hasattr(value, "model_dump"): + try: + return json.dumps(value, default=_json_default, ensure_ascii=False, sort_keys=True) + except Exception: + return str(value) + + # 7) Fallback: string + return str(value) + + +def get_session_logger(session_root: Path, name: str) -> "OtelLogger": + """Get a session-specific OTEL logger. + + Creates a logger that writes to /otel.log with standardized + formatting for OTEL operations across different processes. + + Args: + session_root: Path to the session output directory (not run directory) + name: Name of the logger (e.g., "session_span_manager", "otel_callback") + + Returns: + OtelLogger instance configured for the session + """ + # Import here to avoid circular dependency at module level + from ..observers.logging import get_logger + + log_path = session_root / "otel.log" + # Use session_id to make logger unique per session + # This ensures each session gets its own file handler for otel.log + logger = get_logger(f"{name}.{session_root.name}", str(log_path), console=False) + + return OtelLogger(logger, scope=name) + + +class OtelLogger: + """Structured logger for OpenTelemetry operations. + + Provides standardized logging methods for common OTEL operations like + span creation, attribute setting, context file I/O, etc. + """ + + def __init__(self, logger, scope: str): + self._logger = logger + self._scope = scope + + def _format_message(self, message: str) -> str: + return f"[{self._scope}] {message}" + + def debug(self, message: str) -> None: + self._logger.debug(self._format_message(message)) + + def info(self, message: str) -> None: + self._logger.info(self._format_message(message)) + + def warning(self, message: str) -> None: + self._logger.warning(self._format_message(message)) + + def error(self, message: str) -> None: + self._logger.error(self._format_message(message)) + + # Standardized OTEL operation logging methods + + def log_tracer_init(self, tracer_params: Dict[str, Any]) -> None: + params_str = ", ".join(f"{k}={v}" for k, v in tracer_params.items()) + self.info(f"TRACER_INIT | {params_str}") + + def log_span_start( + self, + span_name: str, + span_id: str, + trace_id: str, + parent_span_id: Optional[str] = None, + is_root: bool = False, + depth: Optional[int] = None, + start_time: Optional[datetime] = None, + ) -> None: + root_marker = " [ROOT]" if is_root else "" + parent_info = f" parent={parent_span_id}" if parent_span_id else "" + depth_info = f" depth={depth}" if depth is not None else "" + timestamp = start_time.strftime("%Y-%m-%d %H:%M:%S.%f") + time_info = f" start_time={timestamp}" if start_time else "" + self.info( + f"SPAN_START{root_marker} | name='{span_name}' id={span_id} trace={trace_id}" + f"{parent_info}{depth_info}{time_info}" + ) + + def log_span_rename(self, old_name: str, new_name: str, span_id: str) -> None: + self.info(f"SPAN_RENAME | '{old_name}' -> '{new_name}' id={span_id}") + + def log_span_end( + self, + span_name: str, + span_id: str, + is_root: bool = False, + status: Optional[str] = None, + depth: Optional[int] = None, + end_time: Optional[datetime] = None, + ) -> None: + root_marker = " [ROOT]" if is_root else "" + status_info = f" status={status}" if status else "" + depth_info = f" depth={depth}" if depth is not None else "" + time_info = f" end_time={end_time.strftime('%Y-%m-%d %H:%M:%S.%f')}" if end_time else "" + self.info(f"SPAN_END{root_marker} | name='{span_name}' id={span_id}{status_info}{depth_info}{time_info}") + + def log_attribute_set(self, key: str, value: Any, span_id: Optional[str] = None) -> None: + span_info = f" span={span_id}" if span_id else "" + # Truncate long values + value_str = str(value) + if len(value_str) > 100: + value_str = value_str[:97] + "..." + self.debug(f"ATTR_SET | {key}={value_str}{span_info}") + + def log_exception(self, exc: Exception, context: Optional[str] = None) -> None: + context_str = f" context={context}" if context else "" + self.error(f"EXCEPTION | {type(exc).__name__}: {exc}{context_str}") + + def log_context_update(self, trace_id: Optional[str], span_id: Optional[str], operation: str = "update") -> None: + """Log OTEL context update operation.""" + self.debug(f"CONTEXT_{operation.upper()} | trace={trace_id} span={span_id}") + + def log_context_read(self, trace_id: str, span_id: str) -> None: + """Log OTEL context read operation.""" + self.debug(f"CONTEXT_READ | trace={trace_id} span={span_id}") diff --git a/labs/AgentStream/exgentic/src/exgentic/utils/paths.py b/labs/AgentStream/exgentic/src/exgentic/utils/paths.py new file mode 100644 index 00000000..e0c771fb --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/utils/paths.py @@ -0,0 +1,192 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +_WINDOWS_FORBIDDEN = set('<>:"/\\|?*') + + +def sanitize_path_component(value: str) -> str: + cleaned = "".join("_" if ch in _WINDOWS_FORBIDDEN else ch for ch in value) + cleaned = cleaned.rstrip(" .") + return cleaned or "run" + + +@dataclass(frozen=True) +class SessionPaths: + """All filesystem paths for a single session. + + Stores resolved values — no lazy lookups, no context dependency. + """ + + session_id: str + run_id: str + output_dir: Path + + def __post_init__(self) -> None: + if not isinstance(self.output_dir, Path): + object.__setattr__(self, "output_dir", Path(self.output_dir)) + + @classmethod + def from_context(cls, ctx) -> SessionPaths: + if ctx.session_id is None: + raise ValueError("Context has no session_id") + return cls(session_id=ctx.session_id, run_id=ctx.run_id, output_dir=ctx.output_dir) + + @property + def root(self) -> Path: + return self.output_dir / self.run_id / "sessions" / self.session_id + + @property + def results(self) -> Path: + return self.root / "results.json" + + @property + def trajectory(self) -> Path: + return self.root / "trajectory.jsonl" + + @property + def benchmark_dir(self) -> Path: + return self.root / "benchmark" + + @property + def agent_dir(self) -> Path: + return self.root / "agent" + + @property + def benchmark_results(self) -> Path: + return self.benchmark_dir / "results.json" + + @property + def benchmark_config(self) -> Path: + return self.benchmark_dir / "config.json" + + @property + def benchmark_task(self) -> Path: + return self.benchmark_dir / "task.json" + + @property + def benchmark_context(self) -> Path: + return self.benchmark_dir / "context.json" + + @property + def session_manifest(self) -> Path: + return self.root / "session.json" + + @property + def session_config(self) -> Path: + return self.root / "config.json" + + @property + def session_log(self) -> Path: + return self.benchmark_dir / "session.log" + + @property + def agent_log(self) -> Path: + return self.agent_dir / "agent.log" + + @property + def error_log(self) -> Path: + return self.root / "error.log" + + @property + def summary(self) -> Path: + return self.root / "summary.json" + + @property + def otel_log(self) -> Path: + return self.root / "otel.log" + + @property + def lock(self) -> Path: + return self.root / "session.lock" + + +@dataclass(frozen=True) +class RunPaths: + """All filesystem paths for a single run. + + Stores resolved values — no lazy lookups, no context dependency. + """ + + run_id: str + output_dir: Path + + def __post_init__(self) -> None: + if not isinstance(self.output_dir, Path): + object.__setattr__(self, "output_dir", Path(self.output_dir)) + + @classmethod + def from_context(cls, ctx) -> RunPaths: + return cls(run_id=ctx.run_id, output_dir=ctx.output_dir) + + @property + def root(self) -> Path: + return self.output_dir / self.run_id + + @property + def sessions_root(self) -> Path: + return self.root / "sessions" + + @property + def run_dir(self) -> Path: + return self.root / "run" + + @property + def results(self) -> Path: + return self.root / "results.json" + + @property + def benchmark_results(self) -> Path: + return self.root / "benchmark_results.json" + + @property + def tracker(self) -> Path: + return self.run_dir / "run.log" + + @property + def warnings(self) -> Path: + return self.run_dir / "warnings.log" + + @property + def config(self) -> Path: + return self.run_dir / "config.json" + + def session(self, session_id: str) -> SessionPaths: + return SessionPaths(session_id=session_id, run_id=self.run_id, output_dir=self.output_dir) + + +# --------------------------------------------------------------------------- +# Convenience accessors — thin wrappers over get_context() +# --------------------------------------------------------------------------- + + +def get_run_id() -> str: + """Return the current run ID from context.""" + from ..core.context import get_context + + return get_context().run_id + + +def get_run_paths() -> RunPaths: + """Return RunPaths for the current context.""" + from ..core.context import get_context + + return RunPaths.from_context(get_context()) + + +def get_session_paths(session_id: str | None = None) -> SessionPaths: + """Return SessionPaths for the given (or current) session. + + If *session_id* is ``None``, uses the session ID from the current context. + """ + from ..core.context import get_context + + ctx = get_context() + sid = session_id or ctx.session_id + if sid is None: + raise ValueError("No session_id provided and none in context") + return RunPaths.from_context(ctx).session(sid) diff --git a/labs/AgentStream/exgentic/src/exgentic/utils/settings.py b/labs/AgentStream/exgentic/src/exgentic/utils/settings.py new file mode 100644 index 00000000..cb818439 --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/utils/settings.py @@ -0,0 +1,142 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import os +from functools import lru_cache +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal + +from dotenv import load_dotenv +from pydantic_settings import BaseSettings, SettingsConfigDict + +DOTENV_PATH = Path(os.environ.get("EXGENTIC_DOTENV_PATH", ".env")) + +load_dotenv(DOTENV_PATH) + +if TYPE_CHECKING: + from ..integrations.litellm.config import LitellmSettings + +RunnerName = Literal["direct", "thread", "process", "service", "docker", "venv"] +LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + + +def resolve_cache_path(cache_dir: str, cache_path: str) -> str: + base = Path(cache_dir).expanduser() + target = Path(cache_path).expanduser() + if target.is_absolute(): + return str(target) + return str(base / target) + + +class ExgenticSettings(BaseSettings): + """Global settings for Exgentic.""" + + default_runner: RunnerName = "venv" + output_dir: str = "./outputs" + # Logging level (env: EXGENTIC_LOG_LEVEL) + log_level: LogLevel = "INFO" + debug: bool = False + litellm_log_level: LogLevel = "WARNING" + litellm_delete_time_from_cache_key: bool = False + litellm_caching: bool = True + cache_dir: str = ".exgentic" + litellm_cache_dir: str = "~/.cache/exgentic/litellm" + dotenv_path: str = str(DOTENV_PATH) + otel_enabled: bool = False + otel_record_content: bool = False + + _litellm_cache_configured: bool = False + + def resolved_litellm_cache_dir(self) -> str: + return resolve_cache_path(self.cache_dir, self.litellm_cache_dir) + + def get_env(self) -> dict[str, str]: + """Return env vars for all settings fields (EXGENTIC_*).""" + env: dict[str, str] = {} + prefix = self.model_config.get("env_prefix") or "" + for name, _field in type(self).model_fields.items(): + if name.startswith("_"): + continue + value = getattr(self, name, None) + if value is None: + continue + key = f"{prefix}{name}".upper() + if isinstance(value, bool): + env[key] = "true" if value else "false" + else: + env[key] = str(value) + return env + + def get_overrides(self) -> dict[str, Any]: + """Return settings values that differ from class defaults.""" + overrides: dict[str, Any] = {} + for name, field in type(self).model_fields.items(): + if name.startswith("_"): + continue + default = field.default + current = getattr(self, name, None) + if current != default: + overrides[name] = current + return overrides + + def model_post_init(self, __context) -> None: + if self.debug and self.log_level != "DEBUG": + object.__setattr__(self, "log_level", "DEBUG") + elif self.log_level == "DEBUG" and not self.debug: + object.__setattr__(self, "debug", True) + self.configure_litellm() + object.__setattr__(self, "_litellm_cache_configured", True) + + def configure_litellm(self, *, cache_only: bool = False) -> None: + from ..integrations.litellm.config import configure_litellm + + configure_litellm(config=self.to_litellm_config(), cache_only=cache_only) + + def to_litellm_config(self) -> LitellmSettings: + from ..integrations.litellm.config import LitellmSettings + + return LitellmSettings( + litellm_caching=self.litellm_caching, + litellm_delete_time_from_cache_key=self.litellm_delete_time_from_cache_key, + cache_dir=self.cache_dir, + litellm_cache_dir=self.litellm_cache_dir, + log_level=self.litellm_log_level, + ) + + def __setattr__(self, name: str, value: Any) -> None: + prev_value = self.__dict__.get(name, None) + super().__setattr__(name, value) + if name == "debug" and value != prev_value and bool(value): + if self.log_level != "DEBUG": + object.__setattr__(self, "log_level", "DEBUG") + if name == "log_level" and value != prev_value: + is_debug = str(value).upper() == "DEBUG" + if self.__dict__.get("debug") != is_debug: + object.__setattr__(self, "debug", is_debug) + if ( + name + in { + "litellm_delete_time_from_cache_key", + "litellm_caching", + "cache_dir", + "litellm_cache_dir", + } + and self._litellm_cache_configured + and value != prev_value + ): + self.configure_litellm(cache_only=True) + object.__setattr__(self, "_litellm_cache_configured", True) + if name == "litellm_log_level" and value != prev_value: + self.configure_litellm(cache_only=False) + + model_config = SettingsConfigDict( + env_prefix="EXGENTIC_", + case_sensitive=False, + ) + + +@lru_cache(maxsize=1) +def get_settings() -> ExgenticSettings: + return ExgenticSettings() # type: ignore[arg-type] diff --git a/labs/AgentStream/exgentic/src/exgentic/utils/sync.py b/labs/AgentStream/exgentic/src/exgentic/utils/sync.py new file mode 100644 index 00000000..7dca3fcb --- /dev/null +++ b/labs/AgentStream/exgentic/src/exgentic/utils/sync.py @@ -0,0 +1,48 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import asyncio +import threading +from typing import Any, Coroutine, Optional + +_loop: Optional[asyncio.AbstractEventLoop] = None +_thread: Optional[threading.Thread] = None +_ready = threading.Event() +_lock = threading.Lock() + + +def _loop_thread_main() -> None: + global _loop + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + _loop = loop + _ready.set() + loop.run_forever() + + +def run_sync(coro: Coroutine[Any, Any, Any], timeout: float | None = None) -> Any: + """Run an async coroutine from sync code using ONE long-lived event loop. + + Safe from any thread in this process. + + Do not call from an async context. + """ + try: + asyncio.get_running_loop() + except RuntimeError: + pass + else: + raise RuntimeError("run_sync() called from a running event loop; use `await` instead") + + global _loop, _thread + with _lock: + if _loop is None or not _loop.is_running(): + _ready.clear() + _thread = threading.Thread(target=_loop_thread_main, name="exgentic-async-loop", daemon=True) + _thread.start() + + if not _ready.wait(timeout=5.0) or _loop is None: + raise RuntimeError("Failed to start shared asyncio loop") + + fut = asyncio.run_coroutine_threadsafe(coro, _loop) + return fut.result(timeout=timeout) diff --git a/labs/AgentStream/exgentic/tests/__init__.py b/labs/AgentStream/exgentic/tests/__init__.py new file mode 100644 index 00000000..a445052e --- /dev/null +++ b/labs/AgentStream/exgentic/tests/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Test package marker.""" diff --git a/labs/AgentStream/exgentic/tests/adapters/runners/__init__.py b/labs/AgentStream/exgentic/tests/adapters/runners/__init__.py new file mode 100644 index 00000000..367fec41 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/adapters/runners/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. diff --git a/labs/AgentStream/exgentic/tests/adapters/runners/conftest.py b/labs/AgentStream/exgentic/tests/adapters/runners/conftest.py new file mode 100644 index 00000000..b70dd0f3 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/adapters/runners/conftest.py @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Shared fixtures for runner/transport tests. + +The ``Calculator`` class and the parametrized ``calc`` fixture are used +across all transport test modules so that every transport is verified +against the exact same behavioural contract. +""" + +from __future__ import annotations + +import pytest +from exgentic.adapters.runners import with_runner +from exgentic.testing.calculator import Calculator, CalculatorError + +# Re-export so existing test imports keep working. +__all__ = ["Calculator", "CalculatorError"] + +# Runners available for the current milestone. +_AVAILABLE_RUNNERS = ["direct", "thread", "process", "service"] + + +@pytest.fixture(params=_AVAILABLE_RUNNERS) +def runner_name(request): + return request.param + + +@pytest.fixture +def calc(runner_name): + proxy = with_runner(Calculator, runner=runner_name, value=10) + yield proxy + try: + proxy.close() + except Exception: + pass diff --git a/labs/AgentStream/exgentic/tests/adapters/runners/test_docker.py b/labs/AgentStream/exgentic/tests/adapters/runners/test_docker.py new file mode 100644 index 00000000..e5741d07 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/adapters/runners/test_docker.py @@ -0,0 +1,69 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Docker runner tests (skipped when Docker is unavailable).""" + +from __future__ import annotations + +import shutil +import subprocess + +import pytest +from exgentic.adapters.runners import with_runner + +from .conftest import Calculator + +# Skip entire module if docker is not available. +_docker_available = shutil.which("docker") is not None +if _docker_available: + try: + subprocess.run(["docker", "info"], check=True, capture_output=True, timeout=5) + except Exception: + _docker_available = False + +pytestmark = [ + pytest.mark.skipif(not _docker_available, reason="Docker not available"), +] + + +@pytest.fixture(scope="module") +def docker_calc(): + """Shared docker-backed Calculator (building is slow).""" + proxy = with_runner( + Calculator, + runner="docker", + env_name="tests/calculator", + module_path="exgentic.testing.calculator", + value=10, + ) + yield proxy + try: + proxy.close() + except Exception: + pass + + +def test_call_method(docker_calc): + assert docker_calc.add(2, 3) == 5 + + +def test_accumulate(docker_calc): + assert docker_calc.accumulate(5) == 15 + + +def test_get_attribute(docker_calc): + assert docker_calc.value == 15 + + +def test_set_attribute(docker_calc): + docker_calc.value = 42 + assert docker_calc.value == 42 + + +def test_error_propagation(docker_calc): + with pytest.raises(ZeroDivisionError): + docker_calc.divide(1, 0) + + +def test_echo(docker_calc): + assert docker_calc.echo({"key": [1, 2, 3]}) == {"key": [1, 2, 3]} diff --git a/labs/AgentStream/exgentic/tests/adapters/runners/test_e2e_session.py b/labs/AgentStream/exgentic/tests/adapters/runners/test_e2e_session.py new file mode 100644 index 00000000..5571be5c --- /dev/null +++ b/labs/AgentStream/exgentic/tests/adapters/runners/test_e2e_session.py @@ -0,0 +1,289 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""End-to-end tests — run a full session lifecycle through every transport. + +Uses the test fixtures (TestSession, TestAgent) to verify that the complete +benchmark→session→agent loop works over each runner/transport layer. +""" + +from __future__ import annotations + +import platform +import shutil +import subprocess +import tempfile +from pathlib import Path + +import pytest +from exgentic.adapters.runners import with_runner +from exgentic.testing import ( + BadAction, + DockerSession, + EmptyArgs, + FinishAction, + GoodAction, + TestAgent, + TestSession, +) + +# Detect Docker availability for conditional tests. +_docker_available = shutil.which("docker") is not None +if _docker_available: + try: + subprocess.run(["docker", "info"], check=True, capture_output=True, timeout=5) + except Exception: + _docker_available = False + +_RUNNERS = ["direct", "thread", "process", "service"] + + +@pytest.fixture(params=_RUNNERS) +def runner_name(request): + return request.param + + +@pytest.fixture +def session_proxy(runner_name, tmp_path, monkeypatch): + """Create a TestSession wrapped in the specified runner.""" + monkeypatch.setenv("EXGENTIC_OUTPUT_DIR", str(tmp_path)) + proxy = with_runner( + TestSession, + runner=runner_name, + task_id="task-1", + session_id="sess-e2e-001", + stop_on_step=False, + invalid_observation=False, + ) + yield proxy + try: + proxy.close() + except Exception: + pass + + +# ── basic lifecycle ────────────────────────────────────────────────── + + +class TestSessionLifecycle: + """Full start → step → done → score lifecycle across transports.""" + + def test_start_returns_observation(self, session_proxy): + obs = session_proxy.start() + assert obs.result == "start" + + def test_step_good_action(self, session_proxy): + session_proxy.start() + obs = session_proxy.step(GoodAction(arguments=EmptyArgs())) + assert obs.result == "step" + + def test_done_false_before_finish(self, session_proxy): + session_proxy.start() + session_proxy.step(GoodAction(arguments=EmptyArgs())) + assert session_proxy.done() is False + + def test_finish_action_marks_done(self, session_proxy): + session_proxy.start() + session_proxy.step(GoodAction(arguments=EmptyArgs())) + obs = session_proxy.step(FinishAction(arguments=EmptyArgs())) + assert obs.result == "finish" + assert session_proxy.done() is True + + def test_score_after_good_and_finish(self, session_proxy): + session_proxy.start() + session_proxy.step(GoodAction(arguments=EmptyArgs())) + session_proxy.step(FinishAction(arguments=EmptyArgs())) + result = session_proxy.score() + assert result.score == 1.0 + assert result.success is True + + def test_score_no_actions(self, session_proxy): + session_proxy.start() + session_proxy.step(FinishAction(arguments=EmptyArgs())) + result = session_proxy.score() + assert result.score == 0.0 + assert result.success is False + + +# ── property access over transports ────────────────────────────────── + + +class TestPropertyAccess: + """Verify that property reads work transparently across transports.""" + + def test_task_property(self, session_proxy): + assert session_proxy.task == "Task task-1" + + def test_task_id_property(self, session_proxy): + assert session_proxy.task_id == "task-1" + + def test_context_property(self, session_proxy): + ctx = session_proxy.context + assert ctx == {"task_id": "task-1"} + + def test_actions_property(self, session_proxy): + actions = session_proxy.actions + assert len(actions) == 3 + names = {a.name for a in actions} + assert names == {"good", "bad", "finish"} + + +# ── agent integration ──────────────────────────────────────────────── + + +class TestAgentWithRunnerSession: + """Run a TestAgent against a session through each transport.""" + + def test_good_then_finish_policy(self, session_proxy): + agent = TestAgent(policy="good_then_finish", finish_after=3) + instance = agent._get_instance_class()( + **agent._get_instance_kwargs(session_id="sess-e2e-001"), + ) + instance.start( + task=session_proxy.task, + context=session_proxy.context, + actions=session_proxy.actions, + ) + + obs = session_proxy.start() + steps = 0 + while not session_proxy.done() and steps < 10: + action = instance.react(obs) + if action is None: + break + obs = session_proxy.step(action) + steps += 1 + + assert session_proxy.done() is True + result = session_proxy.score() + assert result.success is True + assert result.score == 1.0 + + def test_finish_immediately_policy(self, session_proxy): + agent = TestAgent(policy="finish_immediately") + instance = agent._get_instance_class()( + **agent._get_instance_kwargs(session_id="sess-e2e-001"), + ) + instance.start( + task=session_proxy.task, + context=session_proxy.context, + actions=session_proxy.actions, + ) + + obs = session_proxy.start() + action = instance.react(obs) + session_proxy.step(action) + + assert session_proxy.done() is True + result = session_proxy.score() + assert result.score == 0.0 + + +# ── stateful consistency ───────────────────────────────────────────── + + +class TestStatefulConsistency: + """Multiple steps keep consistent state across transports.""" + + def test_multiple_good_actions(self, session_proxy): + session_proxy.start() + for _ in range(5): + obs = session_proxy.step(GoodAction(arguments=EmptyArgs())) + assert obs.result == "step" + session_proxy.step(FinishAction(arguments=EmptyArgs())) + result = session_proxy.score() + assert result.score == 1.0 + assert result.session_metrics["good"] == 5 + assert result.session_metrics["total"] == 5 + + def test_mixed_actions(self, session_proxy): + session_proxy.start() + session_proxy.step(GoodAction(arguments=EmptyArgs())) + session_proxy.step(BadAction(arguments=EmptyArgs())) + session_proxy.step(GoodAction(arguments=EmptyArgs())) + session_proxy.step(FinishAction(arguments=EmptyArgs())) + result = session_proxy.score() + assert result.score == pytest.approx(2 / 3) + assert result.success is False # had bad actions + + +# ── Docker transport (skipped when Docker unavailable) ─────────────── +# +# Uses DockerSession from exgentic.testing — a minimal session-like +# object that is part of the installed package and therefore importable +# inside the Docker container. + + +@pytest.mark.skipif(not _docker_available, reason="Docker not available") +class TestDockerSessionE2E: + """Full session lifecycle over the Docker transport. + + Uses a class-scoped fixture so the image is built only once. + """ + + @pytest.fixture(scope="class") + def docker_session(self): + # Rancher Desktop / Docker Desktop on macOS only share /Users/ by + # default via reverse-sshfs. pytest's tmp_path lives under + # /var/folders/ which is NOT shared, so volume mounts silently fail. + # Use a temp dir under $HOME to ensure Docker can mount it. + if platform.system() == "Darwin": + out = Path(tempfile.mkdtemp(prefix=".exgentic_test_", dir=Path.home())) + else: + out = Path(tempfile.mkdtemp(prefix="exgentic_test_")) + import os + + old_output_dir = os.environ.get("EXGENTIC_OUTPUT_DIR") + os.environ["EXGENTIC_OUTPUT_DIR"] = str(out) + proxy = with_runner( + DockerSession, + runner="docker", + env_name="test/docker-e2e", + module_path="", + task_id="task-1", + output_dir=str(out), + volumes={str(out): str(out)}, + ) + yield proxy, out + try: + proxy.close() + except Exception: + pass + if old_output_dir is None: + os.environ.pop("EXGENTIC_OUTPUT_DIR", None) + else: + os.environ["EXGENTIC_OUTPUT_DIR"] = old_output_dir + shutil.rmtree(out, ignore_errors=True) + + def test_start(self, docker_session): + proxy, _ = docker_session + obs = proxy.start() + assert obs["result"] == "start" + + def test_step_and_finish(self, docker_session): + proxy, _ = docker_session + obs = proxy.step("good") + assert obs["result"] == "step" + obs = proxy.step("finish") + assert obs["result"] == "finish" + assert proxy.done() is True + + def test_score(self, docker_session): + proxy, _ = docker_session + result = proxy.score() + assert result["score"] == 1.0 + assert result["success"] is True + + def test_properties(self, docker_session): + proxy, _ = docker_session + assert proxy.task_id == "task-1" + assert proxy.task == "Task task-1" + assert proxy.context == {"task_id": "task-1"} + + def test_volume_mount_output_visible_on_host(self, docker_session): + """Verify that files written inside the container are visible on the host.""" + proxy, out = docker_session + proxy.write_output("test_result.txt", "hello from docker") + result_file = out / "test_result.txt" + assert result_file.exists(), "Output file written in container not visible on host" + assert result_file.read_text() == "hello from docker" diff --git a/labs/AgentStream/exgentic/tests/adapters/runners/test_process.py b/labs/AgentStream/exgentic/tests/adapters/runners/test_process.py new file mode 100644 index 00000000..40e621cb --- /dev/null +++ b/labs/AgentStream/exgentic/tests/adapters/runners/test_process.py @@ -0,0 +1,43 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Process-specific tests that verify actual process isolation.""" + +from __future__ import annotations + +import gc +import os + +import pytest +from exgentic.adapters.runners import with_runner + +from .conftest import Calculator + + +def _make_process_calc(**kwargs): + try: + return with_runner(Calculator, runner="process", **kwargs) + except PermissionError: + pytest.skip("multiprocessing semaphores not available") + + +def test_runs_in_different_pid(): + calc = _make_process_calc(value=0) + try: + assert calc.pid() != os.getpid() + finally: + calc.close() + + +def test_crash_isolation(): + """Errors in the remote process don't crash the proxy.""" + calc = _make_process_calc(value=0) + try: + with pytest.raises(ZeroDivisionError): + calc.divide(1, 0) + assert calc.add(1, 2) == 3 + finally: + calc.close() + # Force cleanup of multiprocessing resources to avoid interference + # with subsequent thread tests (CPython 3.11 bug workaround). + gc.collect() diff --git a/labs/AgentStream/exgentic/tests/adapters/runners/test_thread.py b/labs/AgentStream/exgentic/tests/adapters/runners/test_thread.py new file mode 100644 index 00000000..757929ea --- /dev/null +++ b/labs/AgentStream/exgentic/tests/adapters/runners/test_thread.py @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Thread-specific tests that verify actual thread isolation.""" + +from __future__ import annotations + +import threading + +from exgentic.adapters.runners import with_runner + +from .conftest import Calculator + + +def test_runs_in_different_thread(): + calc = with_runner(Calculator, runner="thread", value=0) + try: + remote_tid = calc.thread_id() + local_tid = threading.get_ident() + assert remote_tid != local_tid + finally: + calc.close() + + +def test_runs_in_same_pid(): + import os + + calc = with_runner(Calculator, runner="thread", value=0) + try: + assert calc.pid() == os.getpid() + finally: + calc.close() + + +def test_close_joins_thread(): + calc = with_runner(Calculator, runner="thread", value=0) + transport = object.__getattribute__(calc, "_transport") + thread = transport._thread + assert thread is not None and thread.is_alive() + calc.close() + assert not thread.is_alive() diff --git a/labs/AgentStream/exgentic/tests/adapters/runners/test_transport.py b/labs/AgentStream/exgentic/tests/adapters/runners/test_transport.py new file mode 100644 index 00000000..52c26c7c --- /dev/null +++ b/labs/AgentStream/exgentic/tests/adapters/runners/test_transport.py @@ -0,0 +1,101 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Behavioural contract tests — run against every available transport. + +When a new transport is added, append it to ``_AVAILABLE_RUNNERS`` +in ``conftest.py`` and all tests here will automatically cover it. +""" + +from __future__ import annotations + +import pytest + +# ── method calls ───────────────────────────────────────────────────── + + +def test_call_method(calc): + assert calc.add(2, 3) == 5 + + +def test_call_method_kwargs(calc): + assert calc.add(a=2, b=3) == 5 + + +def test_call_method_mixed_args(calc): + assert calc.add(2, b=3) == 5 + + +# ── stateful operations ───────────────────────────────────────────── + + +def test_accumulate(calc): + assert calc.accumulate(5) == 15 # started at 10 + assert calc.accumulate(5) == 20 + + +# ── attribute access ───────────────────────────────────────────────── + + +def test_get_attribute(calc): + assert calc.value == 10 + + +def test_set_attribute(calc): + calc.value = 42 + assert calc.value == 42 + + +# ── error propagation ──────────────────────────────────────────────── + + +def test_builtin_error(calc): + with pytest.raises(ZeroDivisionError): + calc.divide(1, 0) + + +def test_attribute_error(calc): + with pytest.raises(AttributeError): + _ = calc.nonexistent_attribute + + +def test_custom_exception(calc): + """Custom (non-builtin) exceptions preserve type and attributes.""" + from .conftest import CalculatorError + + with pytest.raises(CalculatorError) as exc_info: + calc.fail_custom() + assert "something went wrong" in str(exc_info.value) + assert exc_info.value.code == 42 + + +def test_remote_traceback(calc): + """Non-direct transports attach __remote_traceback__.""" + try: + calc.divide(1, 0) + except ZeroDivisionError as exc: + if hasattr(exc, "__remote_traceback__"): + assert isinstance(exc.__remote_traceback__, str) + + +# ── echo / serialization ──────────────────────────────────────────── + + +def test_echo_int(calc): + assert calc.echo(42) == 42 + + +def test_echo_string(calc): + assert calc.echo("hello") == "hello" + + +def test_echo_list(calc): + assert calc.echo([1, 2, 3]) == [1, 2, 3] + + +def test_echo_dict(calc): + assert calc.echo({"a": 1}) == {"a": 1} + + +def test_echo_none(calc): + assert calc.echo(None) is None diff --git a/labs/AgentStream/exgentic/tests/adapters/runners/test_utils.py b/labs/AgentStream/exgentic/tests/adapters/runners/test_utils.py new file mode 100644 index 00000000..6f59a288 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/adapters/runners/test_utils.py @@ -0,0 +1,80 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Tests for :mod:`exgentic.adapters.runners._utils`.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from exgentic.adapters.runners._utils import find_project_root + + +def test_find_project_root_returns_repo_root(): + """When a pyproject.toml exists in a parent, return that directory.""" + root = find_project_root() + assert (root / "pyproject.toml").exists() + + +def test_find_project_root_falls_back_to_dot_exgentic(tmp_path: Path): + """When no pyproject.toml is found, fall back to ~/.exgentic/.""" + fake_home = tmp_path / "home" + fake_home.mkdir() + + # Create a fake __file__ path with no pyproject.toml in any parent + fake_file = tmp_path / "lib" / "pkg" / "mod.py" + fake_file.parent.mkdir(parents=True) + fake_file.touch() + + with ( + patch( + "exgentic.adapters.runners._utils.Path.__file__", + create=True, + ), + patch( + "exgentic.adapters.runners._utils.Path.home", + return_value=fake_home, + ), + ): + # Patch __file__ at the module level so Path(__file__) resolves + # to a location without pyproject.toml in any ancestor. + import exgentic.adapters.runners._utils as mod + + original_file = mod.__file__ + try: + mod.__file__ = str(fake_file) + result = find_project_root() + finally: + mod.__file__ = original_file + + expected = fake_home / ".exgentic" + assert result == expected + assert expected.is_dir() + + +def test_find_project_root_fallback_is_idempotent(tmp_path: Path): + """Calling find_project_root twice with fallback doesn't error.""" + fake_home = tmp_path / "home" + fake_home.mkdir() + + fake_file = tmp_path / "lib" / "mod.py" + fake_file.parent.mkdir(parents=True) + fake_file.touch() + + with patch( + "exgentic.adapters.runners._utils.Path.home", + return_value=fake_home, + ): + import exgentic.adapters.runners._utils as mod + + original_file = mod.__file__ + try: + mod.__file__ = str(fake_file) + result1 = find_project_root() + result2 = find_project_root() + finally: + mod.__file__ = original_file + + assert result1 == result2 + assert result1 == fake_home / ".exgentic" diff --git a/labs/AgentStream/exgentic/tests/adapters/runners/test_venv.py b/labs/AgentStream/exgentic/tests/adapters/runners/test_venv.py new file mode 100644 index 00000000..dea4b84f --- /dev/null +++ b/labs/AgentStream/exgentic/tests/adapters/runners/test_venv.py @@ -0,0 +1,69 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Venv runner tests (skipped when uv is unavailable).""" + +from __future__ import annotations + +import os +import shutil + +import pytest +from exgentic.adapters.runners import with_runner + +from .conftest import Calculator + +# Skip entire module if uv is not available. +_uv_available = shutil.which("uv") is not None + +pytestmark = [ + pytest.mark.skipif(not _uv_available, reason="uv not available"), +] + + +@pytest.fixture(scope="module") +def venv_calc(): + """Shared venv-backed Calculator (venv creation is slow).""" + proxy = with_runner( + Calculator, + runner="venv", + env_name="tests/calculator", + module_path="exgentic.testing.calculator", + value=10, + ) + yield proxy + try: + proxy.close() + except Exception: + pass + + +def test_call_method(venv_calc): + assert venv_calc.add(2, 3) == 5 + + +def test_accumulate(venv_calc): + assert venv_calc.accumulate(5) == 15 + + +def test_get_attribute(venv_calc): + assert venv_calc.value == 15 + + +def test_set_attribute(venv_calc): + venv_calc.value = 42 + assert venv_calc.value == 42 + + +def test_error_propagation(venv_calc): + with pytest.raises(ZeroDivisionError): + venv_calc.divide(1, 0) + + +def test_echo(venv_calc): + assert venv_calc.echo({"key": [1, 2, 3]}) == {"key": [1, 2, 3]} + + +def test_different_pid(venv_calc): + """Venv runner should run in a separate process.""" + assert venv_calc.pid() != os.getpid() diff --git a/labs/AgentStream/exgentic/tests/agents/cli/test_claude_cli_config.py b/labs/AgentStream/exgentic/tests/agents/cli/test_claude_cli_config.py new file mode 100644 index 00000000..a787b608 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/agents/cli/test_claude_cli_config.py @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Tests for Claude Code CLI configuration writing. + +Verifies that the Claude CLI wrapper pre-creates directories that +the Claude Code CLI expects to write into, preventing EACCES errors +when running inside containers with --user flags. +""" + +from __future__ import annotations + +from exgentic.agents.cli.claude.cli import ClaudeCodeCLI, ExecutionBackend + + +def test_settings_config_creates_required_subdirs(tmp_path): + """_write_settings_config must pre-create subdirectories for the container.""" + cli = ClaudeCodeCLI(runner=ExecutionBackend.PROCESS) + cli._write_settings_config(tmp_path) + + claude_dir = tmp_path / ".claude" + assert claude_dir.is_dir() + assert (claude_dir / "settings.json").exists() + + # These directories must be pre-created so the container + # process doesn't need mkdir permissions on .claude/ + for subdir in ("debug", "conversations", "projects", "todos"): + assert (claude_dir / subdir).is_dir(), ( + f".claude/{subdir} must be pre-created to avoid " f"EACCES errors in container environments" + ) diff --git a/labs/AgentStream/exgentic/tests/agents/cli/test_cli_context_env.py b/labs/AgentStream/exgentic/tests/agents/cli/test_cli_context_env.py new file mode 100644 index 00000000..b79e427a --- /dev/null +++ b/labs/AgentStream/exgentic/tests/agents/cli/test_cli_context_env.py @@ -0,0 +1,54 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from pathlib import Path + +from exgentic.agents.cli.base import ( + BaseCLIConfig, + BaseCLIWrapper, + CLIResult, + ExecutionBackend, +) +from exgentic.core.context import Context, set_context + + +class _DummyRunner: + def __init__(self): + self.env = None + + def run(self, *, cmd, env, cfg_root, config, spawn_error_message, stdin_devnull=False): + self.env = env + return CLIResult(stdout="", stderr="", code=0) + + def close(self) -> None: + return None + + +class _DummyCLI(BaseCLIWrapper): + def build_env(self, *, cfg_root: Path, prompt: str, config: BaseCLIConfig): + return {} + + def build_command(self, *, cfg_root: Path, prompt: str, config: BaseCLIConfig): + return ["echo", "ok"] + + +def test_cli_includes_context_env(): + ctx = Context(run_id="run-cli", output_dir="/tmp/out", cache_dir="/tmp/cache") + set_context(ctx) + + runner = _DummyRunner() + cli = _DummyCLI(runner=ExecutionBackend.PROCESS) + cli.runner = runner + cli.run( + prompt="hi", + config=BaseCLIConfig( + mcp_host="127.0.0.1", + mcp_port=1234, + provider_url="http://example.com", + image="img", + ), + ) + + assert runner.env["EXGENTIC_CTX_RUN_ID"] == "run-cli" diff --git a/labs/AgentStream/exgentic/tests/agents/cli/test_cli_error_surfacing.py b/labs/AgentStream/exgentic/tests/agents/cli/test_cli_error_surfacing.py new file mode 100644 index 00000000..3cd32a0f --- /dev/null +++ b/labs/AgentStream/exgentic/tests/agents/cli/test_cli_error_surfacing.py @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Tests for CLI error surfacing. + +Verifies that when a CLI agent fails, the stderr/stdout from the process +are included in the error message so they propagate through the +coordinator and session orchestrator to the user. +""" + +from __future__ import annotations + +from exgentic.agents.cli.command_runner import CLIExecutionError + + +def test_cli_execution_error_includes_stderr(): + """CLIExecutionError.__str__() must include stderr output.""" + err = CLIExecutionError( + "CLI exited non-zero (1): some-cmd", + code=1, + stdout="", + stderr="Error: permission denied, mkdir '/work/.claude/debug'", + cmd=["some-cmd"], + ) + msg = str(err) + assert "CLI exited non-zero (1)" in msg + assert "STDERR:" in msg + assert "permission denied" in msg + + +def test_cli_execution_error_includes_stdout(): + """CLIExecutionError.__str__() must include stdout when present.""" + err = CLIExecutionError( + "CLI exited non-zero (2): my-cli", + code=2, + stdout="some useful debug output", + stderr="fatal error occurred", + cmd=["my-cli"], + ) + msg = str(err) + assert "STDERR:" in msg + assert "fatal error occurred" in msg + assert "STDOUT:" in msg + assert "some useful debug output" in msg + + +def test_cli_execution_error_omits_empty_streams(): + """CLIExecutionError.__str__() omits STDERR/STDOUT sections when empty.""" + err = CLIExecutionError( + "CLI exited non-zero (1): cmd", + code=1, + stdout="", + stderr="", + cmd=["cmd"], + ) + msg = str(err) + assert "STDERR:" not in msg + assert "STDOUT:" not in msg + assert msg == "CLI exited non-zero (1): cmd" diff --git a/labs/AgentStream/exgentic/tests/agents/test_tool_calling_utils.py b/labs/AgentStream/exgentic/tests/agents/test_tool_calling_utils.py new file mode 100644 index 00000000..f9e40537 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/agents/test_tool_calling_utils.py @@ -0,0 +1,31 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from exgentic.agents.litellm_tool_calling.utils import ToolsActionsRegistry +from exgentic.core.actions import ActionsHandler +from exgentic.core.types import SingleAction +from pydantic import BaseModel + + +class EmptyArgs(BaseModel): + pass + + +class DummyAction(SingleAction): + arguments: EmptyArgs + + +def test_unknown_tool_call_name_yields_unknown_action_observation(): + registry = ToolsActionsRegistry(actions=[]) + tool_calls = [{"name": "not_a_tool", "arguments": "{}", "id": "call-1"}] + + action = registry.tool_calls_to_action(tool_calls) + + assert action is not None + assert action.validation.name_valid is False + assert action.validation.error == "Unknown action" + + handler = ActionsHandler() + observation = handler.execute(action) + + assert "Unknown action" in str(observation.result) diff --git a/labs/AgentStream/exgentic/tests/api/__init__.py b/labs/AgentStream/exgentic/tests/api/__init__.py new file mode 100644 index 00000000..4720acd9 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/api/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""API test package.""" diff --git a/labs/AgentStream/exgentic/tests/api/conftest.py b/labs/AgentStream/exgentic/tests/api/conftest.py new file mode 100644 index 00000000..797faa61 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/api/conftest.py @@ -0,0 +1,37 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from typing import Iterator + +import pytest +from exgentic.interfaces import registry +from exgentic.interfaces.registry import RegistryEntry + + +@pytest.fixture(autouse=True) +def register_test_components() -> Iterator[None]: + original_benchmarks = dict(registry.BENCHMARKS) + original_agents = dict(registry.AGENTS) + registry.BENCHMARKS["test_benchmark"] = RegistryEntry( + slug_name="test_benchmark", + display_name="Test Benchmark", + module="exgentic.testing.benchmark", + attr="TestBenchmark", + kind="benchmark", + ) + registry.AGENTS["test_agent"] = RegistryEntry( + slug_name="test_agent", + display_name="Test Agent", + module="exgentic.testing.agent", + attr="TestAgent", + kind="agent", + ) + try: + yield + finally: + registry.BENCHMARKS.clear() + registry.BENCHMARKS.update(original_benchmarks) + registry.AGENTS.clear() + registry.AGENTS.update(original_agents) diff --git a/labs/AgentStream/exgentic/tests/api/fixtures/__init__.py b/labs/AgentStream/exgentic/tests/api/fixtures/__init__.py new file mode 100644 index 00000000..1090f572 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/api/fixtures/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""API test fixtures.""" diff --git a/labs/AgentStream/exgentic/tests/api/fixtures/test_agent.py b/labs/AgentStream/exgentic/tests/api/fixtures/test_agent.py new file mode 100644 index 00000000..9fadd5aa --- /dev/null +++ b/labs/AgentStream/exgentic/tests/api/fixtures/test_agent.py @@ -0,0 +1,27 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +# Re-export from the installed package so existing tests keep working. +from exgentic.testing.agent import ( + BAD_ACTION_TYPE, + FINISH_ACTION_TYPE, + GOOD_ACTION_TYPE, + BadAction, + EmptyArgs, + FinishAction, + GoodAction, + TestAgent, + TestAgentInstance, +) + +__all__ = [ + "BAD_ACTION_TYPE", + "BadAction", + "EmptyArgs", + "FINISH_ACTION_TYPE", + "FinishAction", + "GOOD_ACTION_TYPE", + "GoodAction", + "TestAgent", + "TestAgentInstance", +] diff --git a/labs/AgentStream/exgentic/tests/api/fixtures/test_benchmark.py b/labs/AgentStream/exgentic/tests/api/fixtures/test_benchmark.py new file mode 100644 index 00000000..91513d62 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/api/fixtures/test_benchmark.py @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +# Re-export from the installed package so existing tests keep working. +from exgentic.testing.benchmark import ( + TestBenchmark, + TestEvaluator, + TestSession, +) + +__all__ = [ + "TestBenchmark", + "TestEvaluator", + "TestSession", +] diff --git a/labs/AgentStream/exgentic/tests/api/test_agent_package_integrity.py b/labs/AgentStream/exgentic/tests/api/test_agent_package_integrity.py new file mode 100644 index 00000000..1798b11b --- /dev/null +++ b/labs/AgentStream/exgentic/tests/api/test_agent_package_integrity.py @@ -0,0 +1,142 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Tests for agent/benchmark package integrity. + +These tests catch two classes of bugs that are easy to introduce when +splitting agent/benchmark modules into separate config and instance files: + +1. Missing ``__init__.py`` — without it, ``importlib.resources.files()`` + cannot discover ``requirements.txt`` / ``setup.sh``, so the CLI's + ``needs_setup()`` returns False and dependencies are never installed. + +2. Host-side import of heavy deps — if ``_get_instance_class_ref()`` falls + back to ``_get_instance_class()`` (which does a lazy import), it will + pull heavy third-party packages (litellm, smolagents, openai-agents) + into the host process. Agents with heavy deps must override + ``_get_instance_class_ref()`` to return a string directly. +""" + +from __future__ import annotations + +import importlib +from pathlib import Path + +from exgentic.interfaces.registry import get_agent_entries, get_benchmark_entries, load_agent + + +def test_all_agent_packages_have_init(): + """Every registered agent's package directory must contain __init__.py. + + Without ``__init__.py``, ``importlib.resources.files()`` cannot + locate ``requirements.txt``, breaking auto-setup. + """ + entries = get_agent_entries() + for slug, entry in entries.items(): + # entry.module is like "exgentic.agents.openai.openai_mcp_agent" + parts = entry.module.split(".") + # Check every package directory from the agent package up + for depth in range(3, len(parts)): + package = ".".join(parts[:depth]) + try: + mod = importlib.import_module(package) + except ImportError: + continue + mod_file = getattr(mod, "__file__", None) + if mod_file is None: + # Namespace package — missing __init__.py + raise AssertionError( + f"Agent '{slug}': package '{package}' is a namespace " + f"package (no __init__.py). This breaks " + f"importlib.resources.files() and prevents " + f"requirements.txt discovery." + ) + + +def test_all_benchmark_packages_have_init(): + """Every registered benchmark's package directory must contain __init__.py.""" + entries = get_benchmark_entries() + for slug, entry in entries.items(): + parts = entry.module.split(".") + for depth in range(3, len(parts)): + package = ".".join(parts[:depth]) + try: + mod = importlib.import_module(package) + except ImportError: + continue + mod_file = getattr(mod, "__file__", None) + if mod_file is None: + raise AssertionError( + f"Benchmark '{slug}': package '{package}' is a namespace " + f"package (no __init__.py). This breaks " + f"importlib.resources.files() and prevents " + f"requirements.txt discovery." + ) + + +def test_agent_instance_class_ref_is_valid_string(): + """Every agent's _get_instance_class_ref() must return a 'module:class' string. + + This verifies the ref is well-formed; it does NOT import the module + (which would defeat the purpose of the string ref). + """ + entries = get_agent_entries() + for slug, _entry in entries.items(): + agent_cls = load_agent(slug) + ref = agent_cls._get_instance_class_ref() + assert isinstance(ref, str), ( + f"Agent '{slug}': _get_instance_class_ref() returned " f"{type(ref).__name__}, expected str" + ) + assert ":" in ref, ( + f"Agent '{slug}': _get_instance_class_ref() returned '{ref}', " f"expected 'module:qualname' format" + ) + module_path, qualname = ref.rsplit(":", 1) + assert module_path, f"Agent '{slug}': empty module path in ref '{ref}'" + assert qualname, f"Agent '{slug}': empty qualname in ref '{ref}'" + + +def test_agent_instance_class_ref_module_file_exists(): + """The module referenced by _get_instance_class_ref() must exist on disk. + + This catches typos in string refs without importing the module. + """ + entries = get_agent_entries() + for slug, entry in entries.items(): + agent_cls = load_agent(slug) + ref = agent_cls._get_instance_class_ref() + module_path, _ = ref.rsplit(":", 1) + # Convert module path to file path + parts = module_path.split(".") + # Find the source root by looking at the agent module's file + agent_mod = importlib.import_module(entry.module) + agent_file = Path(agent_mod.__file__) + # Walk up from the agent file to find the source root + src_root = agent_file + module_parts = entry.module.split(".") + for _ in module_parts: + src_root = src_root.parent + # Now resolve the ref module path + expected_file = src_root / Path(*parts[:-1]) / f"{parts[-1]}.py" + assert expected_file.exists(), ( + f"Agent '{slug}': _get_instance_class_ref() points to " + f"'{module_path}' but {expected_file} does not exist." + ) + + +def test_with_runner_accepts_string_cls(): + """with_runner() must accept a 'module:class' string for the direct runner.""" + from exgentic.adapters.runners import with_runner + + ref = "exgentic.testing.agent:TestAgentInstance" + proxy = with_runner( + ref, + runner="direct", + session_id="test-string-ref", + seed=42, + policy="good_then_finish", + finish_after=2, + max_steps=10, + ) + # Should successfully create the instance + assert proxy is not None + proxy.close() diff --git a/labs/AgentStream/exgentic/tests/api/test_api_errors.py b/labs/AgentStream/exgentic/tests/api/test_api_errors.py new file mode 100644 index 00000000..e44e6589 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/api/test_api_errors.py @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from exgentic import execute +from exgentic.core.types import RunConfig, SessionOutcomeStatus + + +def _base_config(tmp_path, *, run_id: str) -> RunConfig: + return RunConfig( + benchmark="test_benchmark", + agent="test_agent", + output_dir=str(tmp_path / "outputs"), + cache_dir=str(tmp_path / "cache"), + run_id=run_id, + num_tasks=1, + benchmark_kwargs={"tasks": ["task-1"]}, + ) + + +def test_invalid_action_from_agent(tmp_path): + config = _base_config(tmp_path, run_id="run-invalid-action").model_copy( + update={ + "agent_kwargs": {"policy": "invalid_action"}, + } + ) + results = execute(config) + assert results.session_results[0].status == SessionOutcomeStatus.ERROR + + +def test_invalid_observation_from_benchmark(tmp_path): + config = _base_config(tmp_path, run_id="run-invalid-observation").model_copy( + update={ + "agent_kwargs": {"policy": "good_only"}, + "benchmark_kwargs": {"tasks": ["task-1"], "invalid_observation": True}, + } + ) + results = execute(config) + assert results.session_results[0].status == SessionOutcomeStatus.ERROR + + +def test_agent_exception_marks_error(tmp_path): + config = _base_config(tmp_path, run_id="run-agent-error").model_copy( + update={ + "agent_kwargs": {"policy": "raise_error"}, + } + ) + results = execute(config) + assert results.session_results[0].status == SessionOutcomeStatus.ERROR diff --git a/labs/AgentStream/exgentic/tests/api/test_api_files.py b/labs/AgentStream/exgentic/tests/api/test_api_files.py new file mode 100644 index 00000000..275651e3 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/api/test_api_files.py @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import json + +from exgentic import execute +from exgentic.core.types import RunConfig + + +def test_session_files_written(tmp_path): + output_dir = tmp_path / "outputs" + config = RunConfig( + benchmark="test_benchmark", + agent="test_agent", + output_dir=str(output_dir), + cache_dir=str(tmp_path / "cache"), + run_id="run-files", + num_tasks=1, + benchmark_kwargs={"tasks": ["task-1"]}, + agent_kwargs={"policy": "good_then_finish", "finish_after": 2}, + ) + results = execute(config) + session_id = results.session_results[0].session_id + session_root = output_dir / "run-files" / "sessions" / session_id + assert (session_root / "config.json").exists() + assert (session_root / "results.json").exists() + assert (session_root / "session.json").exists() + assert (session_root / "benchmark" / "config.json").exists() + trajectory = session_root / "trajectory.jsonl" + assert trajectory.exists() + first_event = json.loads(trajectory.read_text(encoding="utf-8").splitlines()[0]) + assert first_event["run_id"] == "run-files" diff --git a/labs/AgentStream/exgentic/tests/api/test_api_instances.py b/labs/AgentStream/exgentic/tests/api/test_api_instances.py new file mode 100644 index 00000000..a89a77e8 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/api/test_api_instances.py @@ -0,0 +1,66 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import pytest +from exgentic import execute +from exgentic.core.types import RunConfig + +from .fixtures.test_agent import TestAgent +from .fixtures.test_benchmark import TestBenchmark + + +def test_execute_with_instances(tmp_path): + benchmark = TestBenchmark(tasks=["task-1"]) + agent = TestAgent(policy="good_then_finish", finish_after=2) + results = execute( + benchmark=benchmark, + agent=agent, + output_dir=str(tmp_path / "outputs"), + cache_dir=str(tmp_path / "cache"), + run_id="run-instance", + ) + assert results.total_sessions == 1 + + +def test_instance_kwargs_rejected(tmp_path): + benchmark = TestBenchmark(tasks=["task-1"]) + agent = TestAgent(policy="good_then_finish", finish_after=2) + with pytest.raises(ValueError): + execute( + benchmark=benchmark, + agent=agent, + output_dir=str(tmp_path / "outputs"), + cache_dir=str(tmp_path / "cache"), + run_id="run-instance-bad", + benchmark_kwargs={"tasks": ["task-1"]}, + ) + with pytest.raises(ValueError): + execute( + benchmark=benchmark, + agent=agent, + output_dir=str(tmp_path / "outputs"), + cache_dir=str(tmp_path / "cache"), + run_id="run-instance-bad-2", + agent_kwargs={"policy": "good_only"}, + ) + + +def test_config_and_args_rejected(tmp_path): + run_config = RunConfig( + benchmark="test_benchmark", + agent="test_agent", + output_dir=str(tmp_path / "outputs"), + cache_dir=str(tmp_path / "cache"), + run_id="run-config-ok", + num_tasks=1, + benchmark_kwargs={"tasks": ["task-1"]}, + agent_kwargs={"policy": "good_then_finish", "finish_after": 2}, + ) + with pytest.raises(ValueError): + execute( + run_config, + benchmark="test_benchmark", + agent="test_agent", + ) diff --git a/labs/AgentStream/exgentic/tests/api/test_api_limits.py b/labs/AgentStream/exgentic/tests/api/test_api_limits.py new file mode 100644 index 00000000..80975808 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/api/test_api_limits.py @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from exgentic import execute +from exgentic.core.types import RunConfig, SessionOutcomeStatus + + +def _base_config(tmp_path, *, run_id: str) -> RunConfig: + return RunConfig( + benchmark="test_benchmark", + agent="test_agent", + output_dir=str(tmp_path / "outputs"), + cache_dir=str(tmp_path / "cache"), + run_id=run_id, + num_tasks=1, + benchmark_kwargs={"tasks": ["task-1"]}, + ) + + +def test_action_level_stop(tmp_path): + config = _base_config(tmp_path, run_id="run-action-stop").model_copy( + update={"agent_kwargs": {"policy": "return_none"}} + ) + results = execute(config) + assert results.session_results[0].status == SessionOutcomeStatus.UNFINISHED + + +def test_step_level_stop(tmp_path): + config = _base_config(tmp_path, run_id="run-step-stop").model_copy( + update={ + "benchmark_kwargs": {"tasks": ["task-1"], "stop_on_step": True}, + "agent_kwargs": {"policy": "good_only"}, + } + ) + results = execute(config) + assert results.session_results[0].status == SessionOutcomeStatus.UNFINISHED + + +def test_limit_reached(tmp_path): + config = _base_config(tmp_path, run_id="run-limit").model_copy( + update={ + "agent_kwargs": {"policy": "good_only"}, + "max_steps": 1, + "max_actions": 1, + } + ) + results = execute(config) + assert results.session_results[0].status == SessionOutcomeStatus.LIMIT_REACHED diff --git a/labs/AgentStream/exgentic/tests/api/test_api_missing_results.py b/labs/AgentStream/exgentic/tests/api/test_api_missing_results.py new file mode 100644 index 00000000..9bede4a9 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/api/test_api_missing_results.py @@ -0,0 +1,43 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from exgentic import aggregate, execute +from exgentic.core.types import RunConfig + + +def test_aggregate_logs_missing_results_and_records_ids(tmp_path): + output_dir = tmp_path / "outputs" + run_id = "run-missing-results" + config = RunConfig( + benchmark="test_benchmark", + agent="test_agent", + output_dir=str(output_dir), + cache_dir=str(tmp_path / "cache"), + run_id=run_id, + task_ids=["task-1", "task-2"], + agent_kwargs={"policy": "good_then_finish", "finish_after": 1}, + ) + execute(config) + + missing_session_id = config.to_session_config("task-1").get_session_id() + kept_session_id = config.to_session_config("task-2").get_session_id() + missing_results = output_dir / run_id / "sessions" / missing_session_id / "results.json" + assert missing_results.exists() + missing_results.unlink() + + results = aggregate(config) + + log_path = output_dir / run_id / "run" / "run.log" + log_text = log_path.read_text(encoding="utf-8") + assert "Missing session results for 1/2 planned sessions." in log_text + assert "Missing session ids:" in log_text + + assert results.planned_sessions == 2 + assert results.total_sessions == 1 + assert set(results.planned_session_ids or []) == { + missing_session_id, + kept_session_id, + } + assert results.executed_session_ids == [kept_session_id] diff --git a/labs/AgentStream/exgentic/tests/api/test_api_random.py b/labs/AgentStream/exgentic/tests/api/test_api_random.py new file mode 100644 index 00000000..9d8af819 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/api/test_api_random.py @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from exgentic import execute +from exgentic.core.types import RunConfig + + +def _run(tmp_path, run_id: str): + config = RunConfig( + benchmark="test_benchmark", + agent="test_agent", + output_dir=str(tmp_path / "outputs"), + cache_dir=str(tmp_path / "cache"), + run_id=run_id, + num_tasks=1, + benchmark_kwargs={"tasks": ["task-1"]}, + agent_kwargs={"policy": "random", "seed": 123}, + max_steps=5, + max_actions=5, + ) + return execute(config) + + +def test_random_policy_deterministic(tmp_path): + first = _run(tmp_path, "run-random-1") + second = _run(tmp_path, "run-random-2") + assert first.session_results[0].score == second.session_results[0].score + assert first.session_results[0].status == second.session_results[0].status diff --git a/labs/AgentStream/exgentic/tests/api/test_api_reuse.py b/labs/AgentStream/exgentic/tests/api/test_api_reuse.py new file mode 100644 index 00000000..2c526bb4 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/api/test_api_reuse.py @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import time + +from exgentic import execute +from exgentic.core.types import RunConfig + + +def test_reuse_skips_completed_sessions(tmp_path): + output_dir = tmp_path / "outputs" + run_id = "run-reuse" + config = RunConfig( + benchmark="test_benchmark", + agent="test_agent", + output_dir=str(output_dir), + cache_dir=str(tmp_path / "cache"), + run_id=run_id, + num_tasks=1, + benchmark_kwargs={"tasks": ["task-1"]}, + agent_kwargs={"policy": "good_then_finish", "finish_after": 2}, + ) + execute(config) + log_path = output_dir / run_id / "run" / "run.log" + assert log_path.exists() + session_id = config.to_session_config("task-1").get_session_id() + results_path = output_dir / run_id / "sessions" / session_id / "results.json" + bench_results_path = output_dir / run_id / "sessions" / session_id / "benchmark" / "results.json" + assert results_path.exists() + assert bench_results_path.exists() + before_results_mtime = results_path.stat().st_mtime_ns + before_bench_mtime = bench_results_path.stat().st_mtime_ns + + time.sleep(0.01) + execute(config) + after_results_mtime = results_path.stat().st_mtime_ns + after_bench_mtime = bench_results_path.stat().st_mtime_ns + + assert after_results_mtime == before_results_mtime + assert after_bench_mtime == before_bench_mtime diff --git a/labs/AgentStream/exgentic/tests/api/test_api_run_config.py b/labs/AgentStream/exgentic/tests/api/test_api_run_config.py new file mode 100644 index 00000000..1963dd10 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/api/test_api_run_config.py @@ -0,0 +1,87 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import json + +from exgentic import ( + aggregate, + evaluate, + execute, + list_agents, + list_benchmarks, + list_subsets, + list_tasks, + preview, + results, + status, +) +from exgentic.core.types import RunConfig, SessionOutcomeStatus + + +def _run_config(tmp_path, *, run_id: str, policy: str = "good_then_finish") -> RunConfig: + return RunConfig( + benchmark="test_benchmark", + agent="test_agent", + output_dir=str(tmp_path / "outputs"), + cache_dir=str(tmp_path / "cache"), + run_id=run_id, + num_tasks=1, + max_steps=5, + max_actions=5, + benchmark_kwargs={"tasks": ["task-1"]}, + agent_kwargs={"policy": policy, "finish_after": 2}, + ) + + +def test_evaluate_run_config_writes_files(tmp_path): + config = _run_config(tmp_path, run_id="run-eval") + run_results = evaluate(config) + assert run_results.total_sessions == 1 + assert run_results.successful_sessions == 1 + assert run_results.benchmark_score == 1.0 + session = run_results.session_results[0] + assert session.score == 1.0 + assert session.status == SessionOutcomeStatus.SUCCESS + + session_id = config.to_session_config("task-1").get_session_id() + run_root = tmp_path / "outputs" / "run-eval" + assert (run_root / "run" / "config.json").exists() + assert (run_root / "results.json").exists() + assert (run_root / "benchmark_results.json").exists() + assert (run_root / "sessions" / session_id / "config.json").exists() + assert (run_root / "sessions" / session_id / "results.json").exists() + assert (run_root / "sessions" / session_id / "benchmark" / "results.json").exists() + + payload = json.loads((run_root / "results.json").read_text(encoding="utf-8")) + assert payload["total_sessions"] == 1 + + +def test_execute_then_aggregate(tmp_path): + config = _run_config(tmp_path, run_id="run-exec") + exec_results = execute(config) + assert exec_results.benchmark_score is None + agg_results = aggregate(config) + assert agg_results.benchmark_score == 1.0 + + +def test_preview_status_results(tmp_path): + config = _run_config(tmp_path, run_id="run-preview") + plan = preview(config) + assert len(plan.to_run) == 1 + run_status = status(config) + assert run_status.total_tasks == 1 + evaluate(config) + loaded = results(config) + assert loaded.total_sessions == 1 + + +def test_listing_apis(): + benchmarks = list_benchmarks() + agents = list_agents() + assert any(item["slug_name"] == "test_benchmark" for item in benchmarks) + assert any(item["slug_name"] == "test_agent" for item in agents) + assert list_subsets("test_benchmark") == [] + tasks = list_tasks(benchmark="test_benchmark") + assert "task-1" in tasks diff --git a/labs/AgentStream/exgentic/tests/api/test_api_runners.py b/labs/AgentStream/exgentic/tests/api/test_api_runners.py new file mode 100644 index 00000000..92857a4b --- /dev/null +++ b/labs/AgentStream/exgentic/tests/api/test_api_runners.py @@ -0,0 +1,165 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""End-to-end evaluate flow through every runner. + +Exercises the full evaluate() pipeline — evaluator creation, session execution, +scoring, close(), and aggregation — through each transport layer. + +This catches issues like: +- ContextVar propagation across uvicorn thread-pool workers (service runner) +- Path resolution mismatches between host and runner +- close() forwarding through proxied objects +- Result file persistence across runner boundaries +- Volume mount and env var consistency (docker runner) +""" + +from __future__ import annotations + +import json +import platform +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest +from exgentic import evaluate, execute +from exgentic.core.types import RunConfig + +_RUNNERS = ["direct", "thread", "process", "service"] + +# Detect Docker availability for conditional tests. +_docker_available = shutil.which("docker") is not None +if _docker_available: + try: + subprocess.run(["docker", "info"], check=True, capture_output=True, timeout=5) + except Exception: + _docker_available = False + +if _docker_available and sys.version_info[:2] == (3, 12): + _RUNNERS.append("docker") + + +@pytest.fixture(params=_RUNNERS) +def runner(request): + return request.param + + +@pytest.fixture(scope="module") +def _docker_tmpdir(): + """Module-scoped temp dir for Docker tests under $HOME. + + Rancher Desktop / Docker Desktop on macOS only share ``/Users/`` by + default. pytest's ``tmp_path`` lives under ``/var/folders/`` which + is NOT shared, so Docker volume mounts silently fail. + + Using a module-scoped parent dir avoids cleaning up temp dirs between + tests, which would break stale logging FileHandlers held by the + evaluate framework. + """ + if platform.system() != "Darwin": + yield None + return + d = Path(tempfile.mkdtemp(prefix=".exgentic_test_", dir=Path.home())) + yield d + shutil.rmtree(d, ignore_errors=True) + + +@pytest.fixture +def run_tmp(runner, tmp_path, _docker_tmpdir): + """Temp path that works with Docker volume mounts on macOS.""" + if runner != "docker" or _docker_tmpdir is None: + return tmp_path + d = Path(tempfile.mkdtemp(dir=_docker_tmpdir)) + return d + + +def _run_config(tmp_path, runner: str, *, num_tasks: int = 2, policy: str = "good_then_finish") -> RunConfig: + tasks = [f"task-{i}" for i in range(1, num_tasks + 1)] + return RunConfig( + benchmark="test_benchmark", + agent="test_agent", + output_dir=str(tmp_path / "outputs"), + cache_dir=str(tmp_path / "cache"), + run_id=f"run-{runner}", + num_tasks=num_tasks, + max_steps=10, + max_actions=10, + benchmark_kwargs={"tasks": tasks, "runner": runner}, + agent_kwargs={"policy": policy, "finish_after": 2}, + ) + + +def test_evaluate_full_flow(run_tmp, runner): + """Full evaluate (sessions + aggregation) through each runner.""" + config = _run_config(run_tmp, runner) + results = evaluate(config) + + assert results.total_sessions == 2 + assert results.successful_sessions == 2 + assert results.benchmark_score == 1.0 + + for session in results.session_results: + assert session.score == 1.0 + assert session.success is True + + +def test_result_files_written(run_tmp, runner): + """Verify all expected output files are created through each runner.""" + config = _run_config(run_tmp, runner, num_tasks=1) + evaluate(config) + + run_dir = run_tmp / "outputs" / f"run-{runner}" + session_id = config.to_session_config("task-1").get_session_id() + session_dir = run_dir / "sessions" / session_id + + # Run-level files + assert (run_dir / "results.json").exists() + assert (run_dir / "benchmark_results.json").exists() + + # Session-level files + assert (session_dir / "results.json").exists() + assert (session_dir / "benchmark" / "results.json").exists() + + # Verify content is valid JSON with expected fields + payload = json.loads((run_dir / "results.json").read_text()) + assert payload["total_sessions"] == 1 + assert payload["benchmark_score"] == 1.0 + + +def test_execute_then_aggregate(run_tmp, runner): + """Verify execute-only + aggregate-only works through each runner.""" + from exgentic import aggregate + + config = _run_config(run_tmp, runner, num_tasks=1) + + exec_results = execute(config) + assert exec_results.benchmark_score is None + assert exec_results.total_sessions == 1 + + agg_results = aggregate(config) + assert agg_results.benchmark_score == 1.0 + + +def test_unsuccessful_session(run_tmp, runner): + """Agent that finishes immediately scores 0 through each runner.""" + config = _run_config(run_tmp, runner, num_tasks=1, policy="finish_immediately") + results = evaluate(config) + + assert results.total_sessions == 1 + assert results.successful_sessions == 0 + assert results.session_results[0].score == 0.0 + assert results.session_results[0].success is False + + +def test_parallel_workers(run_tmp, runner): + """Evaluate with max_workers>1 to test thread-safety of pickling.""" + config = _run_config(run_tmp, runner, num_tasks=4) + config = config.model_copy(update={"max_workers": 2}) + results = evaluate(config) + + assert results.total_sessions == 4 + assert results.successful_sessions == 4 + assert results.benchmark_score == 1.0 diff --git a/labs/AgentStream/exgentic/tests/api/test_api_session_config.py b/labs/AgentStream/exgentic/tests/api/test_api_session_config.py new file mode 100644 index 00000000..1fc32945 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/api/test_api_session_config.py @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from exgentic import execute, status +from exgentic.core.types import RunConfig, SessionOutcomeStatus + + +def test_execute_session_config(tmp_path): + run_config = RunConfig( + benchmark="test_benchmark", + agent="test_agent", + output_dir=str(tmp_path / "outputs"), + cache_dir=str(tmp_path / "cache"), + run_id="run-session", + num_tasks=1, + benchmark_kwargs={"tasks": ["task-1"]}, + agent_kwargs={"policy": "good_then_finish", "finish_after": 2}, + ) + session_config = run_config.to_session_config("task-1") + results = execute(session_config) + assert results.total_sessions == 1 + assert results.session_results[0].status == SessionOutcomeStatus.SUCCESS + + +def test_status_session_config(tmp_path): + run_config = RunConfig( + benchmark="test_benchmark", + agent="test_agent", + output_dir=str(tmp_path / "outputs"), + cache_dir=str(tmp_path / "cache"), + run_id="run-status-session", + num_tasks=1, + benchmark_kwargs={"tasks": ["task-1"]}, + agent_kwargs={"policy": "good_then_finish", "finish_after": 2}, + ) + session_config = run_config.to_session_config("task-1") + run_status = status(session_config) + assert run_status.total_tasks == 1 + assert run_status.task_ids == ["task-1"] diff --git a/labs/AgentStream/exgentic/tests/api/test_cli_batch.py b/labs/AgentStream/exgentic/tests/api/test_cli_batch.py new file mode 100644 index 00000000..b40039e7 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/api/test_cli_batch.py @@ -0,0 +1,132 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import csv +import json + +from click.testing import CliRunner +from exgentic.core.types import RunConfig +from exgentic.interfaces.cli.main import cli + + +def _write_config(path, *, run_id: str) -> str: + cfg = RunConfig( + benchmark="test_benchmark", + agent="test_agent", + output_dir=str(path.parent / "outputs"), + cache_dir=str(path.parent / "cache"), + run_id=run_id, + num_tasks=1, + benchmark_kwargs={"tasks": ["task-1"]}, + agent_kwargs={"policy": "good_then_finish", "finish_after": 2}, + ) + path.write_text(cfg.model_dump_json(indent=2), encoding="utf-8") + return str(path) + + +def test_batch_status_multiple_configs(tmp_path): + runner = CliRunner() + c1 = _write_config(tmp_path / "a.json", run_id="run-a") + c2 = _write_config(tmp_path / "b.json", run_id="run-b") + + result = runner.invoke( + cli, + [ + "batch", + "status", + "--config", + c1, + "--config", + c2, + ], + ) + + assert result.exit_code == 0, result.output + assert "Batch Status" in result.output + assert "run-a" in result.output + assert "run-b" in result.output + + +def test_batch_status_config_glob_pattern(tmp_path): + runner = CliRunner() + p1 = tmp_path / "outputs" / "r1" / "config.json" + p2 = tmp_path / "outputs" / "r2" / "config.json" + p1.parent.mkdir(parents=True, exist_ok=True) + p2.parent.mkdir(parents=True, exist_ok=True) + _write_config(p1, run_id="run-g1") + _write_config(p2, run_id="run-g2") + + result = runner.invoke( + cli, + [ + "batch", + "status", + "--config", + str(tmp_path / "outputs" / "**" / "config.json"), + ], + ) + + assert result.exit_code == 0, result.output + assert "run-g1" in result.output + assert "run-g2" in result.output + + +def test_batch_status_shell_expanded_values_after_single_config(tmp_path): + runner = CliRunner() + c1 = _write_config(tmp_path / "s1.json", run_id="run-s1") + c2 = _write_config(tmp_path / "s2.json", run_id="run-s2") + + # Simulates shell expansion where one --config token becomes multiple values. + result = runner.invoke( + cli, + [ + "batch", + "status", + "--config", + c1, + c2, + ], + ) + + assert result.exit_code == 0, result.output + assert "run-s1" in result.output + assert "run-s2" in result.output + + +def test_batch_extract_writes_csv(tmp_path): + runner = CliRunner() + config_path = _write_config(tmp_path / "extract.json", run_id="run-extract") + + results_path = tmp_path / "run-extract" / "results.json" + results_path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "benchmark_name": "Test Benchmark", + "agent_name": "Test Agent", + "benchmark_score": 1.0, + "total_sessions": 1, + } + results_path.write_text(json.dumps(payload), encoding="utf-8") + + output_csv = tmp_path / "results.csv" + result = runner.invoke( + cli, + [ + "batch", + "extract", + "--config", + config_path, + "--output", + str(output_csv), + ], + ) + + assert result.exit_code == 0, result.output + with open(output_csv, encoding="utf-8", newline="") as f: + reader = csv.DictReader(f) + rows = list(reader) + + assert len(rows) == 1 + assert rows[0]["benchmark_name"] == "Test Benchmark" + assert rows[0]["benchmark_score"] == "1.0" diff --git a/labs/AgentStream/exgentic/tests/api/test_cli_commands.py b/labs/AgentStream/exgentic/tests/api/test_cli_commands.py new file mode 100644 index 00000000..61b1acec --- /dev/null +++ b/labs/AgentStream/exgentic/tests/api/test_cli_commands.py @@ -0,0 +1,102 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from click.testing import CliRunner +from exgentic.core.types import RunConfig +from exgentic.interfaces.cli.main import cli + + +def test_cli_execute_concurrent(tmp_path): + runner = CliRunner() + output_dir = tmp_path / "outputs" + result = runner.invoke( + cli, + [ + "evaluate", + "execute", + "--benchmark", + "test_benchmark", + "--agent", + "test_agent", + "--task", + "task-1", + "--task", + "task-2", + "--set", + 'agent.policy="good_then_finish"', + "--set", + "agent.finish_after=2", + "--max-workers", + "2", + "--run-id", + "run-cli-concurrent", + "--output-dir", + str(output_dir), + ], + ) + assert result.exit_code == 0, result.output + sessions_root = output_dir / "run-cli-concurrent" / "sessions" + session_dirs = [p for p in sessions_root.iterdir() if (p / "config.json").exists()] + assert len(session_dirs) == 2 + + +def test_cli_execute_session_config(tmp_path): + runner = CliRunner() + output_dir = tmp_path / "outputs" + config = RunConfig( + benchmark="test_benchmark", + agent="test_agent", + output_dir=str(output_dir), + cache_dir=str(tmp_path / "cache"), + run_id="run-cli-session", + num_tasks=1, + benchmark_kwargs={"tasks": ["task-1"]}, + agent_kwargs={"policy": "good_then_finish", "finish_after": 2}, + ) + session_config = config.to_session_config("task-1") + config_path = tmp_path / "session_config.json" + config_path.write_text(session_config.model_dump_json(indent=2), encoding="utf-8") + + result = runner.invoke( + cli, + [ + "evaluate", + "session", + "--config", + str(config_path), + ], + ) + assert result.exit_code == 0, result.output + sessions_root = output_dir / "run-cli-session" / "sessions" + session_dirs = [p for p in sessions_root.iterdir() if (p / "config.json").exists()] + assert len(session_dirs) == 1 + + +def test_cli_status_with_config(tmp_path): + runner = CliRunner() + output_dir = tmp_path / "outputs" + config = RunConfig( + benchmark="test_benchmark", + agent="test_agent", + output_dir=str(output_dir), + cache_dir=str(tmp_path / "cache"), + run_id="run-cli-status", + num_tasks=1, + benchmark_kwargs={"tasks": ["task-1"]}, + agent_kwargs={"policy": "good_then_finish", "finish_after": 2}, + ) + config_path = tmp_path / "run_config.json" + config_path.write_text(config.model_dump_json(indent=2), encoding="utf-8") + + result = runner.invoke( + cli, + [ + "status", + "--config", + str(config_path), + ], + ) + assert result.exit_code == 0, result.output + assert "run-cli-status" in result.output diff --git a/labs/AgentStream/exgentic/tests/api/test_cli_compare.py b/labs/AgentStream/exgentic/tests/api/test_cli_compare.py new file mode 100644 index 00000000..c5c14167 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/api/test_cli_compare.py @@ -0,0 +1,1638 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import json +import re +from pathlib import Path + +from click.testing import CliRunner +from exgentic.core.types import SessionResults +from exgentic.core.types.session import SessionOutcomeStatus +from exgentic.interfaces.cli.main import cli + + +def create_mock_results( + output_dir: Path, + agent: str, + model: str, + benchmark: str, + subset: str | None, + tasks: list[tuple[str, float]], + run_id: str | None = None, +) -> None: + """Create mock results structure for testing compare command. + + Args: + output_dir: Base output directory + agent: Agent slug name + model: Model name + benchmark: Benchmark slug name + subset: Optional subset name + tasks: List of (task_id, score) tuples + run_id: Optional run ID (generated if not provided) + """ + if run_id is None: + run_id = f"test-{agent}-{model}-{benchmark}" + if subset: + run_id += f"-{subset}" + + run_dir = output_dir / run_id + run_dir.mkdir(parents=True, exist_ok=True) + + # Create config.json in run/config.json format + config_dir = run_dir / "run" + config_dir.mkdir(parents=True, exist_ok=True) + + config_data = { + "benchmark": {"slug_name": benchmark}, + "agent": {"slug_name": agent, "model_name": model}, + "model": model, + "run_id": run_id, + } + + if subset: + config_data["benchmark"]["params"] = {"subset": subset} + config_data["subset"] = subset + + config_file = config_dir / "config.json" + config_file.write_text(json.dumps(config_data, indent=2), encoding="utf-8") + + # Create sessions directory with results + sessions_dir = run_dir / "sessions" + sessions_dir.mkdir(parents=True, exist_ok=True) + + for task_id, score in tasks: + session_id = f"session-{task_id}" + session_dir = sessions_dir / session_id + session_dir.mkdir(parents=True, exist_ok=True) + + # Create results.json + session_result = SessionResults( + session_id=session_id, + task_id=task_id, + success=score >= 0.99, + score=score, + is_finished=True, + status=SessionOutcomeStatus.SUCCESS, + steps=5, + action_count=5, + invalid_action_count=0, + agent_cost=0.01, + benchmark_cost=0.0, + execution_time=10.0, + ) + + results_file = session_dir / "results.json" + results_file.write_text(session_result.model_dump_json(indent=2), encoding="utf-8") + + +def test_compare_two_agents_same_benchmark(tmp_path): + """Test comparing two different agents using the same model on a single benchmark. + + Verifies: + - Comparison table is displayed with task results + - Basic comparison functionality works + - Task-level results are shown correctly + """ + runner = CliRunner() + output_dir = tmp_path / "outputs" + + # Create mock results for agent1 + create_mock_results( + output_dir, + agent="agent1", + model="model1", + benchmark="test_benchmark", + subset=None, + tasks=[("task-1", 1.0), ("task-2", 0.8), ("task-3", 0.6)], + ) + + # Create mock results for agent2 + create_mock_results( + output_dir, + agent="agent2", + model="model1", + benchmark="test_benchmark", + subset=None, + tasks=[("task-1", 0.9), ("task-2", 0.85), ("task-3", 0.7)], + ) + + result = runner.invoke( + cli, + [ + "compare", + "--agent1", + "agent1", + "--agent2", + "agent2", + "--model1", + "model1", + "--model2", + "model1", + "--benchmark", + "test_benchmark", + "--output-dir", + str(output_dir), + ], + ) + + assert result.exit_code == 0, result.output + assert "Comparison Results" in result.output + # New format shows summary table with setup names and statistics + assert "agent1 and model1" in result.output + assert "agent2 and model1" in result.output + assert "Statistical Significance Matrix" in result.output + + +def test_compare_two_models_same_agent(tmp_path): + """Test comparing two different models using the same agent. + + Verifies: + - Model comparison works correctly + - Model-specific filtering is applied + - Results are properly differentiated by model + """ + runner = CliRunner() + output_dir = tmp_path / "outputs" + + # Create mock results for model1 + create_mock_results( + output_dir, + agent="agent1", + model="model1", + benchmark="test_benchmark", + subset=None, + tasks=[("task-1", 1.0), ("task-2", 0.9)], + ) + + # Create mock results for model2 + create_mock_results( + output_dir, + agent="agent1", + model="model2", + benchmark="test_benchmark", + subset=None, + tasks=[("task-1", 0.8), ("task-2", 0.7)], + ) + + result = runner.invoke( + cli, + [ + "compare", + "--agent1", + "agent1", + "--agent2", + "agent1", + "--model1", + "model1", + "--model2", + "model2", + "--benchmark", + "test_benchmark", + "--output-dir", + str(output_dir), + ], + ) + + assert result.exit_code == 0, result.output + assert "Comparison Results" in result.output + # Verify model-specific filtering is applied - both models should appear + assert "model1" in result.output + assert "model2" in result.output + # New format shows summary table + assert "Statistical Significance Matrix" in result.output + + +def test_compare_with_subset(tmp_path): + """Test comparing results with benchmark subsets (e.g., benchmark/subset). + + Verifies: + - Subset filtering works correctly + - Subset parameter is properly parsed and applied + - Results are filtered to the specified subset + """ + runner = CliRunner() + output_dir = tmp_path / "outputs" + + # Create mock results with subset + create_mock_results( + output_dir, + agent="agent1", + model="model1", + benchmark="test_benchmark", + subset="subset1", + tasks=[("task-1", 1.0), ("task-2", 0.8)], + ) + + create_mock_results( + output_dir, + agent="agent2", + model="model1", + benchmark="test_benchmark", + subset="subset1", + tasks=[("task-1", 0.9), ("task-2", 0.85)], + ) + + result = runner.invoke( + cli, + [ + "compare", + "--agent1", + "agent1", + "--agent2", + "agent2", + "--model1", + "model1", + "--model2", + "model1", + "--benchmark", + "test_benchmark/subset1", + "--output-dir", + str(output_dir), + ], + ) + + assert result.exit_code == 0, result.output + # Verify subset parameter is properly parsed and applied + assert "test_benchmark/subset1" in result.output + # New format shows summary table + assert "Statistical Significance Matrix" in result.output + + +def test_compare_json_output(tmp_path): + """Test JSON output format (--format json). + + Verifies: + - JSON structure includes setups, per_benchmark, and overall + - Proper JSON serialization of all data + - Summary statistics are included in JSON output + """ + runner = CliRunner() + output_dir = tmp_path / "outputs" + + create_mock_results( + output_dir, + agent="agent1", + model="model1", + benchmark="test_benchmark", + subset=None, + tasks=[("task-1", 1.0), ("task-2", 0.8)], + ) + + create_mock_results( + output_dir, + agent="agent2", + model="model1", + benchmark="test_benchmark", + subset=None, + tasks=[("task-1", 0.9), ("task-2", 0.85)], + ) + + result = runner.invoke( + cli, + [ + "compare", + "--agent1", + "agent1", + "--agent2", + "agent2", + "--model1", + "model1", + "--model2", + "model1", + "--benchmark", + "test_benchmark", + "--output-dir", + str(output_dir), + "--format", + "json", + ], + ) + + assert result.exit_code == 0, result.output + + # Parse JSON output - new structure + output_data = json.loads(result.output) + assert "setups" in output_data + assert "per_benchmark" in output_data + assert "overall" in output_data + assert len(output_data["setups"]) == 2 + assert "test_benchmark" in output_data["per_benchmark"] + assert len(output_data["overall"]["pairwise_comparisons"]) == 1 + + +def test_compare_multiple_benchmarks(tmp_path): + """Test comparing across multiple benchmarks simultaneously. + + Verifies: + - Multiple benchmarks can be compared in one command + - Overall statistics are computed with equal weight per benchmark + - "Overall" section appears in output + - Per-benchmark and overall summaries are both shown + """ + import warnings + + runner = CliRunner() + output_dir = tmp_path / "outputs" + + # Create results for benchmark1 + create_mock_results( + output_dir, + agent="agent1", + model="model1", + benchmark="benchmark1", + subset=None, + tasks=[("task-1", 1.0), ("task-2", 0.8)], + ) + + create_mock_results( + output_dir, + agent="agent2", + model="model1", + benchmark="benchmark1", + subset=None, + tasks=[("task-1", 0.9), ("task-2", 0.85)], + ) + + # Create results for benchmark2 + create_mock_results( + output_dir, + agent="agent1", + model="model1", + benchmark="benchmark2", + subset=None, + tasks=[("task-1", 0.95), ("task-2", 0.75)], + ) + + create_mock_results( + output_dir, + agent="agent2", + model="model1", + benchmark="benchmark2", + subset=None, + tasks=[("task-1", 0.85), ("task-2", 0.8)], + ) + + # Suppress statsmodels warnings for small sample sizes + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=RuntimeWarning, module="statsmodels") + result = runner.invoke( + cli, + [ + "compare", + "--agent1", + "agent1", + "--agent2", + "agent2", + "--model1", + "model1", + "--model2", + "model1", + "--benchmark", + "benchmark1", + "--benchmark", + "benchmark2", + "--output-dir", + str(output_dir), + ], + ) + + assert result.exit_code == 0, result.output + assert "benchmark1" in result.output + assert "benchmark2" in result.output + assert "Overall" in result.output + + +def test_compare_pairwise_three_agents(tmp_path): + """Test pairwise comparison mode with 3 agents. + + Verifies: + - Pairwise comparison summary table is displayed + - All pair combinations are computed (3 pairs for 3 agents) + - Statistical significance matrix is shown + """ + runner = CliRunner() + output_dir = tmp_path / "outputs" + + # Create results for three agents + for benchmark_num in [1, 2]: + for agent_num in [1, 2, 3]: + score_offset = (agent_num - 1) * 0.1 + create_mock_results( + output_dir, + agent=f"agent{agent_num}", + model="model1", + benchmark=f"test_benchmark{benchmark_num}", + subset=None, + tasks=[ + ("task-1", 1.0 - score_offset), + ("task-2", 0.9 - score_offset), + ], + ) + + result = runner.invoke( + cli, + [ + "compare", + "--agent1", + "agent1", + "--agent2", + "agent2", + "--agent3", + "agent3", + "--model1", + "model1", + "--model2", + "model1", + "--model3", + "model1", + "--benchmark", + "test_benchmark1", + "--benchmark", + "test_benchmark2", + "--output-dir", + str(output_dir), + ], + ) + # print(result.output) + assert result.exit_code == 0, result.output + assert "Comparison Results" in result.output + # Verify all pair combinations are computed (3 pairs for 3 agents) + # Should see comparisons: agent1 vs agent2, agent1 vs agent3, agent2 vs agent3 + assert "agent1 and model1" in result.output + assert "agent2 and model1" in result.output + assert "agent3 and model1" in result.output + assert "Detailed Comparison: test_benchmark1" in result.output + assert "Detailed Comparison: test_benchmark2" in result.output + assert "Detailed Comparison: test_benchmark2" in result.output + assert re.search( + r"Detailed Comparison.*" r"agent1 and model1.*agent2 and model1.*" r"agent1 and model1.*agent2 and model1.*", + result.output, + flags=re.DOTALL, + ) + assert "Statistical Significance Matrix" in result.output + + +def test_compare_missing_results(tmp_path): + """Test error handling when results are missing for one setup. + + Verifies: + - Appropriate error message is displayed + - Suggests the correct 'exgentic evaluate' command to run + - Exits with non-zero code + """ + runner = CliRunner() + output_dir = tmp_path / "outputs" + + # Only create results for agent1, not agent2 + create_mock_results( + output_dir, + agent="agent1", + model="model1", + benchmark="test_benchmark", + subset=None, + tasks=[("task-1", 1.0)], + ) + + result = runner.invoke( + cli, + [ + "compare", + "--agent1", + "agent1", + "--agent2", + "agent2", + "--model1", + "model1", + "--model2", + "model1", + "--benchmark", + "test_benchmark", + "--output-dir", + str(output_dir), + ], + ) + + assert result.exit_code != 0 + assert "No results found" in result.output + # Verify suggests the correct 'exgentic evaluate' command to run + assert "exgentic evaluate" in result.output or "evaluate" in result.output.lower() + + +def test_compare_mismatched_tasks(tmp_path): + """Test comparing when agents have different task sets. + + Verifies: + - N/A is shown for missing tasks + - Comparison continues with available tasks + - No errors are raised for mismatched task sets + """ + runner = CliRunner() + output_dir = tmp_path / "outputs" + + # Agent1 has tasks 1 and 2 + create_mock_results( + output_dir, + agent="agent1", + model="model1", + benchmark="test_benchmark", + subset=None, + tasks=[("task-1", 1.0), ("task-2", 0.8)], + ) + + # Agent2 has tasks 2 and 3 + create_mock_results( + output_dir, + agent="agent2", + model="model1", + benchmark="test_benchmark", + subset=None, + tasks=[("task-2", 0.85), ("task-3", 0.7)], + ) + + result = runner.invoke( + cli, + [ + "compare", + "--agent1", + "agent1", + "--agent2", + "agent2", + "--model1", + "model1", + "--model2", + "model1", + "--benchmark", + "test_benchmark", + "--output-dir", + str(output_dir), + ], + ) + + assert result.exit_code == 0, result.output + # New format only compares overlapping tasks, shows # Tasks = 1 + assert "Statistical Significance Matrix" in result.output + # Only task-2 is compared (overlaps between both agents) + assert "1" in result.output # Should show 1 task compared + + +def test_compare_invalid_input_patterns(tmp_path): + """Test validation of input patterns (must be consistent). + + Verifies: + - Error is raised for invalid combinations + - All setups must follow same pattern (all with agent+model, all with agent only, or all with model only) + - Appropriate error message explains valid patterns + """ + runner = CliRunner() + output_dir = tmp_path / "outputs" + + # Invalid: agent1 has model, agent2 doesn't + result = runner.invoke( + cli, + [ + "compare", + "--agent1", + "agent1", + "--agent2", + "agent2", + "--model1", + "model1", + "--benchmark", + "test_benchmark", + "--output-dir", + str(output_dir), + ], + ) + + assert result.exit_code != 0 + assert "Invalid input combination" in result.output + # Verify appropriate error message explains valid patterns + assert "pattern" in result.output.lower() or "consistent" in result.output.lower() + + +def test_compare_requires_two_setups(tmp_path): + """Test that at least 2 setups are required for comparison. + + Verifies: + - Error is raised when only one setup is provided + - Appropriate error message is shown + - Minimum comparison requirement is enforced + """ + runner = CliRunner() + output_dir = tmp_path / "outputs" + + result = runner.invoke( + cli, + [ + "compare", + "--agent1", + "agent1", + "--model1", + "model1", + "--benchmark", + "test_benchmark", + "--output-dir", + str(output_dir), + ], + ) + + assert result.exit_code != 0 + assert "At least 2 agent/model setups are required" in result.output + + +def test_compare_statistical_significance_significant(tmp_path): + """Test that statistical significance is detected when there's a clear difference. + + Verifies: + - Statistical significance is detected for clear differences + - McNemar's test is used + - p-value is below significance threshold (< 0.05) + - Significance status is correctly reported + """ + runner = CliRunner() + output_dir = tmp_path / "outputs" + + # Create results with clear significant difference + # Agent1 succeeds on all tasks (score >= 0.99) + # Agent2 fails on all tasks (score < 0.99) + create_mock_results( + output_dir, + agent="agent1", + model="model1", + benchmark="test_benchmark", + subset=None, + tasks=[ + ("task-1", 1.0), + ("task-2", 1.0), + ("task-3", 1.0), + ("task-4", 1.0), + ("task-5", 1.0), + ("task-6", 1.0), + ("task-7", 1.0), + ("task-8", 1.0), + ("task-9", 1.0), + ("task-10", 1.0), + ], + ) + + create_mock_results( + output_dir, + agent="agent2", + model="model1", + benchmark="test_benchmark", + subset=None, + tasks=[ + ("task-1", 0.5), + ("task-2", 0.5), + ("task-3", 0.5), + ("task-4", 0.5), + ("task-5", 0.5), + ("task-6", 0.5), + ("task-7", 0.5), + ("task-8", 0.5), + ("task-9", 0.5), + ("task-10", 0.5), + ], + ) + + result = runner.invoke( + cli, + [ + "compare", + "--agent1", + "agent1", + "--agent2", + "agent2", + "--model1", + "model1", + "--model2", + "model1", + "--benchmark", + "test_benchmark", + "--output-dir", + str(output_dir), + "--format", + "json", + ], + ) + + assert result.exit_code == 0, result.output + + # Parse JSON output for precise checks - new structure + output_data = json.loads(result.output) + + # Check per-benchmark results + benchmark_comparison = output_data["per_benchmark"]["test_benchmark"][0] + assert benchmark_comparison["num_tasks"] == 10 + assert benchmark_comparison["success_rate1"] == 1.0 + assert benchmark_comparison["success_rate2"] == 0.0 + assert benchmark_comparison["rate_difference"] == 1.0 + assert benchmark_comparison["is_significant"] is True + assert benchmark_comparison["p_value"] < 0.05 + + # Check overall results + overall_comparison = output_data["overall"]["pairwise_comparisons"][0] + assert overall_comparison["num_tasks"] == 10 + assert overall_comparison["is_significant"] is True + assert overall_comparison["p_value"] < 0.05 + + +def test_compare_statistical_significance_not_significant(tmp_path): + """Test that non-significant results are correctly identified. + + Verifies: + - Non-significant results are correctly identified + - is_significant flag is False + - p-value is above significance threshold + - Appropriate message indicates non-significance + """ + runner = CliRunner() + output_dir = tmp_path / "outputs" + + # Create results with no significant difference + # Both agents have identical performance (all succeed) + create_mock_results( + output_dir, + agent="agent1", + model="model1", + benchmark="test_benchmark", + subset=None, + tasks=[ + ("task-1", 1.0), + ("task-2", 1.0), + ("task-3", 1.0), + ("task-4", 1.0), + ("task-5", 1.0), + ], + ) + + create_mock_results( + output_dir, + agent="agent2", + model="model1", + benchmark="test_benchmark", + subset=None, + tasks=[ + ("task-1", 1.0), + ("task-2", 1.0), + ("task-3", 1.0), + ("task-4", 1.0), + ("task-5", 1.0), + ], + ) + + result = runner.invoke( + cli, + [ + "compare", + "--agent1", + "agent1", + "--agent2", + "agent2", + "--model1", + "model1", + "--model2", + "model1", + "--benchmark", + "test_benchmark", + "--output-dir", + str(output_dir), + "--format", + "json", + ], + ) + + assert result.exit_code == 0, result.output + + # Parse JSON output - new structure + output_data = json.loads(result.output) + + # Check per-benchmark results + benchmark_comparison = output_data["per_benchmark"]["test_benchmark"][0] + assert benchmark_comparison["num_tasks"] == 5 + assert benchmark_comparison["success_rate1"] == 1.0 + assert benchmark_comparison["success_rate2"] == 1.0 + assert benchmark_comparison["rate_difference"] == 0.0 + assert benchmark_comparison["is_significant"] is False + assert benchmark_comparison["p_value"] >= 0.1 + + +def test_compare_statistical_significance_marginal(tmp_path): + """Test marginal significance detection (small but detectable difference). + + Verifies: + - Marginal differences are detected + - Average scores are computed correctly + - Statistical test handles partial disagreements + - p-value reflects the marginal difference + """ + runner = CliRunner() + output_dir = tmp_path / "outputs" + + # Create results with marginal difference + # Agent1 succeeds on 7/10 tasks, Agent2 succeeds on 3/10 tasks + # This creates 4 disagreements where agent1 wins + create_mock_results( + output_dir, + agent="agent1", + model="model1", + benchmark="test_benchmark", + subset=None, + tasks=[ + ("task-1", 1.0), # success (both succeed) + ("task-2", 1.0), # success (both succeed) + ("task-3", 1.0), # success (both succeed) + ("task-4", 1.0), # success (agent1 wins) + ("task-5", 1.0), # success (agent1 wins) + ("task-6", 1.0), # success (agent1 wins) + ("task-7", 1.0), # success (agent1 wins) + ("task-8", 0.5), # fail (both fail) + ("task-9", 0.5), # fail (both fail) + ("task-10", 0.5), # fail (both fail) + ], + ) + + create_mock_results( + output_dir, + agent="agent2", + model="model1", + benchmark="test_benchmark", + subset=None, + tasks=[ + ("task-1", 1.0), # success (both succeed) + ("task-2", 1.0), # success (both succeed) + ("task-3", 1.0), # success (both succeed) + ("task-4", 0.5), # fail (agent1 wins) + ("task-5", 0.5), # fail (agent1 wins) + ("task-6", 0.5), # fail (agent1 wins) + ("task-7", 0.5), # fail (agent1 wins) + ("task-8", 0.5), # fail (both fail) + ("task-9", 0.5), # fail (both fail) + ("task-10", 0.5), # fail (both fail) + ], + ) + + result = runner.invoke( + cli, + [ + "compare", + "--agent1", + "agent1", + "--agent2", + "agent2", + "--model1", + "model1", + "--model2", + "model1", + "--benchmark", + "test_benchmark", + "--output-dir", + str(output_dir), + "--format", + "json", + ], + ) + + assert result.exit_code == 0, result.output + + # Parse JSON output - new structure + output_data = json.loads(result.output) + + # Check per-benchmark results + benchmark_comparison = output_data["per_benchmark"]["test_benchmark"][0] + assert benchmark_comparison["num_tasks"] == 10 + + # Verify success rates are computed correctly + # Agent1: 7 successes (score >= 0.99) out of 10 = 0.7 + # Agent2: 3 successes (score >= 0.99) out of 10 = 0.3 + assert abs(benchmark_comparison["success_rate1"] - 0.7) < 0.01 + assert abs(benchmark_comparison["success_rate2"] - 0.3) < 0.01 + assert abs(benchmark_comparison["rate_difference"] - 0.4) < 0.01 + + # Verify p-value reflects the marginal difference + assert benchmark_comparison["p_value"] < 0.5 + + +def test_compare_statistical_significance_text_output(tmp_path): + """Test that statistical significance is displayed correctly in text output. + + Verifies: + - "Statistical Significance" section appears + - Test method name is shown (McNemar's test) + - p-value is displayed + - Task count is shown + - Average scores are displayed for both setups + """ + runner = CliRunner() + output_dir = tmp_path / "outputs" + + # Create results with significant difference + create_mock_results( + output_dir, + agent="agent1", + model="model1", + benchmark="test_benchmark", + subset=None, + tasks=[ + ("task-1", 1.0), + ("task-2", 1.0), + ("task-3", 1.0), + ("task-4", 1.0), + ("task-5", 1.0), + ], + ) + + create_mock_results( + output_dir, + agent="agent2", + model="model1", + benchmark="test_benchmark", + subset=None, + tasks=[ + ("task-1", 0.5), + ("task-2", 0.5), + ("task-3", 0.5), + ("task-4", 0.5), + ("task-5", 0.5), + ], + ) + + result = runner.invoke( + cli, + [ + "compare", + "--agent1", + "agent1", + "--agent2", + "agent2", + "--model1", + "model1", + "--model2", + "model1", + "--benchmark", + "test_benchmark", + "--output-dir", + str(output_dir), + ], + ) + + assert result.exit_code == 0, result.output + # New format shows summary table + assert "Statistical Significance Matrix" in result.output + assert "Comparison Results" in result.output + # Verify task count is shown (in the # Tasks column) + assert "5" in result.output + + +def test_compare_five_agents_max(tmp_path): + """Test comparing the maximum of 5 agents/models. + + Verifies: + - All pairwise combinations are computed (10 pairs for 5 agents) + - Pairwise summary table is generated correctly + - System handles maximum allowed setups + """ + runner = CliRunner() + output_dir = tmp_path / "outputs" + + # Create results for 5 agents + for agent_num in range(1, 6): + create_mock_results( + output_dir, + agent=f"agent{agent_num}", + model="model1", + benchmark="test_benchmark", + subset=None, + tasks=[("task-1", 1.0 - agent_num * 0.1)], + ) + + result = runner.invoke( + cli, + [ + "compare", + "--agent1", + "agent1", + "--agent2", + "agent2", + "--agent3", + "agent3", + "--agent4", + "agent4", + "--agent5", + "agent5", + "--model1", + "model1", + "--model2", + "model1", + "--model3", + "model1", + "--model4", + "model1", + "--model5", + "model1", + "--benchmark", + "test_benchmark", + "--output-dir", + str(output_dir), + ], + ) + + assert result.exit_code == 0, result.output + assert "Statistical Significance Matrix" in result.output + # Verify all 5 agents appear in output (system handles maximum allowed setups) + for agent_num in range(1, 6): + assert f"agent{agent_num}" in result.output + + +def test_compare_only_agents_no_models(tmp_path): + """Test comparing agents without specifying models. + + Verifies: + - Comparison works across any models when only agents are specified + - Flexible input pattern is supported + - Results are aggregated correctly regardless of model + """ + runner = CliRunner() + output_dir = tmp_path / "outputs" + + # Create results for two agents (any model) + create_mock_results( + output_dir, + agent="agent1", + model="model1", + benchmark="test_benchmark", + subset=None, + tasks=[("task-1", 1.0)], + ) + + create_mock_results( + output_dir, + agent="agent2", + model="model1", + benchmark="test_benchmark", + subset=None, + tasks=[("task-1", 0.9)], + ) + + result = runner.invoke( + cli, + [ + "compare", + "--agent1", + "agent1", + "--agent2", + "agent2", + "--benchmark", + "test_benchmark", + "--output-dir", + str(output_dir), + ], + ) + + assert result.exit_code == 0, result.output + # Verify comparison works across any models (both agents should appear) + assert "agent1" in result.output + assert "agent2" in result.output + # New format shows summary table + assert "Statistical Significance Matrix" in result.output + + +def test_compare_only_models_no_agents(tmp_path): + """Test comparing models without specifying agents. + + Verifies: + - Comparison works across any agents when only models are specified + - Model-only comparison mode is supported + - Results are aggregated correctly regardless of agent + """ + runner = CliRunner() + output_dir = tmp_path / "outputs" + + # Create results for two models (any agent) + create_mock_results( + output_dir, + agent="agent1", + model="model1", + benchmark="test_benchmark", + subset=None, + tasks=[("task-1", 1.0)], + ) + + create_mock_results( + output_dir, + agent="agent2", + model="model1", + benchmark="test_benchmark", + subset=None, + tasks=[("task-1", 1.0)], + ) + + create_mock_results( + output_dir, + agent="agent1", + model="model2", + benchmark="test_benchmark", + subset=None, + tasks=[("task-1", 0)], + ) + + create_mock_results( + output_dir, + agent="agent2", + model="model2", + benchmark="test_benchmark", + subset=None, + tasks=[("task-1", 1.0)], + ) + result = runner.invoke( + cli, + [ + "compare", + "--model1", + "model1", + "--model2", + "model2", + "--benchmark", + "test_benchmark", + "--output-dir", + str(output_dir), + ], + ) + assert result.exit_code == 0, result.output + # Verify comparison works across any agents (both models should appear) + assert "model1" in result.output + assert "model2" in result.output + assert "50." in result.output or "50…" in result.output # May be truncated as "50.…" in table + # New format shows summary table + assert "Statistical Significance Matrix" in result.output + + +def test_breslow_day_homogeneous_high_pvalue(tmp_path): + """Test Breslow-Day test with homogeneous data (high p-value). + + Creates data where the effect is consistent across benchmarks: + - Both benchmarks show similar odds ratios + - Should result in high p-value (p > 0.05) indicating homogeneity + """ + runner = CliRunner() + output_dir = tmp_path / "outputs" + output_dir.mkdir() + + # Create consistent effect across two benchmarks + # Contingency table: [[4, 3], [1, 2]] -> OR = 2.67 (same for both benchmarks) + # Benchmark 1: Agent1 wins 7/10, Agent2 wins 5/10 + create_mock_results( + output_dir, + agent="agent1", + model="model1", + benchmark="benchmark1", + subset=None, + tasks=[ + # Both succeed (4 tasks) + ("task1", 1.0), + ("task2", 1.0), + ("task3", 1.0), + ("task4", 1.0), + # Agent1 only (3 tasks) + ("task5", 1.0), + ("task6", 1.0), + ("task7", 1.0), + # Agent2 only (1 task) + ("task8", 0.0), + # Both fail (2 tasks) + ("task9", 0.0), + ("task10", 0.0), + ], + ) + + create_mock_results( + output_dir, + agent="agent2", + model="model1", + benchmark="benchmark1", + subset=None, + tasks=[ + # Both succeed (4 tasks) + ("task1", 1.0), + ("task2", 1.0), + ("task3", 1.0), + ("task4", 1.0), + # Agent1 only (3 tasks) - Agent2 fails + ("task5", 0.0), + ("task6", 0.0), + ("task7", 0.0), + # Agent2 only (1 task) + ("task8", 1.0), + # Both fail (2 tasks) + ("task9", 0.0), + ("task10", 0.0), + ], + ) + + # Benchmark 2: Same pattern - Agent1 wins 7/10, Agent2 wins 5/10 + create_mock_results( + output_dir, + agent="agent1", + model="model1", + benchmark="benchmark2", + subset=None, + tasks=[ + # Both succeed (4 tasks) + ("task1", 1.0), + ("task2", 1.0), + ("task3", 1.0), + ("task4", 1.0), + # Agent1 only (3 tasks) + ("task5", 1.0), + ("task6", 1.0), + ("task7", 1.0), + # Agent2 only (1 task) + ("task8", 0.0), + # Both fail (2 tasks) + ("task9", 0.0), + ("task10", 0.0), + ], + ) + + create_mock_results( + output_dir, + agent="agent2", + model="model1", + benchmark="benchmark2", + subset=None, + tasks=[ + # Both succeed (4 tasks) + ("task1", 1.0), + ("task2", 1.0), + ("task3", 1.0), + ("task4", 1.0), + # Agent1 only (3 tasks) - Agent2 fails + ("task5", 0.0), + ("task6", 0.0), + ("task7", 0.0), + # Agent2 only (1 task) + ("task8", 1.0), + # Both fail (2 tasks) + ("task9", 0.0), + ("task10", 0.0), + ], + ) + + # Run compare command with JSON output + result = runner.invoke( + cli, + [ + "compare", + "--agent1", + "agent1", + "--agent2", + "agent2", + "--benchmark", + "benchmark1", + "--benchmark", + "benchmark2", + "--output-dir", + str(output_dir), + "--format", + "json", + ], + ) + + assert result.exit_code == 0, f"Command failed: {result.output}" + + # Parse JSON output - new structure + output_data = json.loads(result.output) + + # Check that overall statistics include Breslow-Day test in pairwise comparisons + assert "overall" in output_data + assert "pairwise_comparisons" in output_data["overall"] + assert len(output_data["overall"]["pairwise_comparisons"]) > 0 + + # Get Breslow-Day test from first pairwise comparison + breslow_day = output_data["overall"]["pairwise_comparisons"][0]["breslow_day"] + assert breslow_day is not None + assert "p_value" in breslow_day + assert "interpretation" in breslow_day + + # High p-value indicates homogeneity (consistent effect across benchmarks) + assert ( + breslow_day["p_value"] > 0.05 + ), f"Expected high p-value (>0.05) for homogeneous data, got {breslow_day['p_value']}" + assert ( + "homogeneous" in breslow_day["interpretation"].lower() or "consistent" in breslow_day["interpretation"].lower() + ), f"Expected 'homogeneous' or 'consistent' in interpretation, got: {breslow_day['interpretation']}" + + +def test_breslow_day_heterogeneous_low_pvalue(tmp_path): + """Test Breslow-Day test with heterogeneous data (low p-value). + + Creates data where the effect varies across benchmarks: + - Benchmark 1: Agent1 much better than Agent2 + - Benchmark 2: Agent2 much better than Agent1 + - Should result in low p-value (p < 0.05) indicating heterogeneity + + This creates contingency tables with different odds ratios: + - Benchmark 1: OR = 1.5 (Agent1 better) + - Benchmark 2: OR = 0.0185 (Agent2 better) + """ + runner = CliRunner() + output_dir = tmp_path / "outputs" + output_dir.mkdir() + + # Benchmark 1: Agent1 better (21/27 vs 8/27) + # Contingency table: [[18, 6], [2, 1]] -> OR = 1.5 + # Both succeed: 18, Agent1 only: 3, Agent2 only: 2, Both fail: 1 + create_mock_results( + output_dir, + agent="agent1", + model="model1", + benchmark="benchmark1", + subset=None, + tasks=[ + # Both succeed (18 tasks) + ("task1", 1.0), + ("task2", 1.0), + ("task3", 1.0), + ("task4", 1.0), + ("task5", 1.0), + ("task6", 1.0), + ("task7", 1.0), + ("task8", 1.0), + ("task9", 1.0), + ("task10", 1.0), + ("task11", 1.0), + ("task12", 1.0), + ("task13", 1.0), + ("task14", 1.0), + ("task15", 1.0), + ("task16", 1.0), + ("task17", 1.0), + ("task18", 1.0), + # Agent1 only (6 tasks) + ("task19", 1.0), + ("task20", 1.0), + ("task21", 1.0), + ("task22", 1.0), + ("task23", 1.0), + ("task24", 1.0), + # Both fail (1 task) + ("task27", 0.0), + ], + ) + + create_mock_results( + output_dir, + agent="agent2", + model="model1", + benchmark="benchmark1", + subset=None, + tasks=[ + # Both succeed (18 tasks) + ("task1", 1.0), + ("task2", 1.0), + ("task3", 1.0), + ("task4", 1.0), + ("task5", 1.0), + ("task6", 1.0), + ("task7", 1.0), + ("task8", 1.0), + ("task9", 1.0), + ("task10", 1.0), + ("task11", 1.0), + ("task12", 1.0), + ("task13", 1.0), + ("task14", 1.0), + ("task15", 1.0), + ("task16", 1.0), + ("task17", 1.0), + ("task18", 1.0), + # Agent1 only (6 tasks) - Agent2 fails + ("task19", 0.0), + ("task20", 0.0), + ("task21", 0.0), + ("task22", 0.0), + ("task23", 0.0), + ("task24", 0.0), + # Agent2 only (2 tasks) + ("task25", 1.0), + ("task26", 1.0), + # Both fail (1 task) + ("task27", 0.0), + ], + ) + + # Benchmark 2: Agent2 better (8/27 vs 21/27) - opposite pattern + # Contingency table: [[2, 18], [6, 1]] -> OR = 0.0185 + create_mock_results( + output_dir, + agent="agent1", + model="model1", + benchmark="benchmark2", + subset=None, + tasks=[ + # Both succeed (2 tasks) + ("task1", 1.0), + ("task2", 1.0), + # Agent2 only (18 tasks) - Agent1 fails + ("task3", 0.0), + ("task4", 0.0), + ("task5", 0.0), + ("task6", 0.0), + ("task7", 0.0), + ("task8", 0.0), + ("task9", 0.0), + ("task10", 0.0), + ("task11", 0.0), + ("task12", 0.0), + ("task13", 0.0), + ("task14", 0.0), + ("task15", 0.0), + ("task16", 0.0), + ("task17", 0.0), + ("task18", 0.0), + ("task19", 0.0), + ("task20", 0.0), + # Agent1 only (6 tasks) + ("task21", 1.0), + ("task22", 1.0), + ("task23", 1.0), + ("task24", 1.0), + ("task25", 1.0), + ("task26", 1.0), + # Both fail (1 task) + ("task27", 0.0), + ], + ) + + create_mock_results( + output_dir, + agent="agent2", + model="model1", + benchmark="benchmark2", + subset=None, + tasks=[ + # Both succeed (2 tasks) + ("task1", 1.0), + ("task2", 1.0), + # Agent2 only (18 tasks) + ("task3", 1.0), + ("task4", 1.0), + ("task5", 1.0), + ("task6", 1.0), + ("task7", 1.0), + ("task8", 1.0), + ("task9", 1.0), + ("task10", 1.0), + ("task11", 1.0), + ("task12", 1.0), + ("task13", 1.0), + ("task14", 1.0), + ("task15", 1.0), + ("task16", 1.0), + ("task17", 1.0), + ("task18", 1.0), + ("task19", 1.0), + ("task20", 1.0), + # Agent1 only (6 tasks) - Agent2 fails + ("task21", 0.0), + ("task22", 0.0), + ("task23", 0.0), + ("task24", 0.0), + ("task25", 0.0), + ("task26", 0.0), + # Both fail (1 task) + ("task27", 0.0), + ], + ) + + # Run compare command with JSON output + result = runner.invoke( + cli, + [ + "compare", + "--agent1", + "agent1", + "--agent2", + "agent2", + "--benchmark", + "benchmark1", + "--benchmark", + "benchmark2", + "--output-dir", + str(output_dir), + "--format", + "json", + ], + ) + + assert result.exit_code == 0, f"Command failed: {result.output}" + + # Parse JSON output - new structure + output_data = json.loads(result.output) + + # Check that overall statistics include Breslow-Day test in pairwise comparisons + assert "overall" in output_data + assert "pairwise_comparisons" in output_data["overall"] + assert len(output_data["overall"]["pairwise_comparisons"]) > 0 + + # Get Breslow-Day test from first pairwise comparison + breslow_day = output_data["overall"]["pairwise_comparisons"][0]["breslow_day"] + assert breslow_day is not None + assert "p_value" in breslow_day + assert "interpretation" in breslow_day + + # Low p-value indicates heterogeneity (effect varies across benchmarks) + assert ( + breslow_day["p_value"] < 0.05 + ), f"Expected low p-value (<0.05) for heterogeneous data, got {breslow_day['p_value']}" + assert ( + "heterogeneity" in breslow_day["interpretation"].lower() or "varies" in breslow_day["interpretation"].lower() + ), f"Expected 'heterogeneity' or 'varies' in interpretation, got: {breslow_day['interpretation']}" + + +def test_breslow_day_text_output(tmp_path): + """Test that Breslow-Day test results appear in text output.""" + import warnings + + runner = CliRunner() + output_dir = tmp_path / "outputs" + output_dir.mkdir() + + # Create data for two benchmarks + create_mock_results( + output_dir, + agent="agent1", + model="model1", + benchmark="benchmark1", + subset=None, + tasks=[("task1", 1.0), ("task2", 1.0), ("task3", 0.0)], + ) + + create_mock_results( + output_dir, + agent="agent2", + model="model1", + benchmark="benchmark1", + subset=None, + tasks=[("task1", 1.0), ("task2", 0.0), ("task3", 0.0)], + ) + + create_mock_results( + output_dir, + agent="agent1", + model="model1", + benchmark="benchmark2", + subset=None, + tasks=[("task1", 1.0), ("task2", 1.0), ("task3", 0.0)], + ) + + create_mock_results( + output_dir, + agent="agent2", + model="model1", + benchmark="benchmark2", + subset=None, + tasks=[("task1", 1.0), ("task2", 0.0), ("task3", 0.0)], + ) + + # Run compare command with text output (default) + # Suppress statsmodels warnings for small sample sizes + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=RuntimeWarning, module="statsmodels") + result = runner.invoke( + cli, + [ + "compare", + "--agent1", + "agent1", + "--agent2", + "agent2", + "--benchmark", + "benchmark1", + "--benchmark", + "benchmark2", + "--output-dir", + str(output_dir), + ], + ) + + assert result.exit_code == 0, f"Command failed: {result.output}" + + # Check that text output includes Breslow-Day test information + # New format shows it in the overall table section + assert ( + "Breslow-Day" in result.output or "breslow" in result.output.lower() or "Bre…" in result.output + ), "Expected Breslow-Day test information in text output" + assert ( + "P-value" in result.output or "p-value" in result.output or "p=" in result.output + ), "Expected p-value in text output" + assert ( + "Interpretation" in result.output + or "homogeneous" in result.output.lower() + or "consistent" in result.output.lower() + or "hom…" in result.output + or "con…" in result.output + ), "Expected interpretation in text output" diff --git a/labs/AgentStream/exgentic/tests/api/test_cli_version.py b/labs/AgentStream/exgentic/tests/api/test_cli_version.py new file mode 100644 index 00000000..7045ce7e --- /dev/null +++ b/labs/AgentStream/exgentic/tests/api/test_cli_version.py @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from click.testing import CliRunner +from exgentic import __version__ +from exgentic.interfaces.cli.main import cli + + +def test_cli_version_long_flag(): + """Test that --version flag displays version and exits.""" + runner = CliRunner() + result = runner.invoke(cli, ["--version"]) + assert result.exit_code == 0 + assert f"exgentic {__version__}" in result.output + + +def test_cli_version_short_flag(): + """Test that -V flag displays version and exits.""" + runner = CliRunner() + result = runner.invoke(cli, ["-V"]) + assert result.exit_code == 0 + assert f"exgentic {__version__}" in result.output diff --git a/labs/AgentStream/exgentic/tests/api/test_package_exports.py b/labs/AgentStream/exgentic/tests/api/test_package_exports.py new file mode 100644 index 00000000..3d7e924a --- /dev/null +++ b/labs/AgentStream/exgentic/tests/api/test_package_exports.py @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import exgentic + +from tests.api.fixtures.test_agent import TestAgent +from tests.api.fixtures.test_benchmark import TestBenchmark + + +def test_top_level_registry_class_imports_via_from_import(): + scope: dict[str, object] = {} + exec("from exgentic import TestAgent, TestBenchmark", {}, scope) + assert scope["TestAgent"] is TestAgent + assert scope["TestBenchmark"] is TestBenchmark + + +def test_top_level_registry_class_imports_via_attribute_access(): + assert exgentic.TestAgent is TestAgent + assert exgentic.TestBenchmark is TestBenchmark + + +def test_unknown_top_level_export_raises_attribute_error(): + try: + _ = exgentic.NotARealExport + except AttributeError as exc: + assert "NotARealExport" in str(exc) + else: + raise AssertionError("Expected AttributeError for unknown top-level export.") diff --git a/labs/AgentStream/exgentic/tests/benchmarks/__init__.py b/labs/AgentStream/exgentic/tests/benchmarks/__init__.py new file mode 100644 index 00000000..367fec41 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/benchmarks/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. diff --git a/labs/AgentStream/exgentic/tests/benchmarks/recordings/appworld/recording.json b/labs/AgentStream/exgentic/tests/benchmarks/recordings/appworld/recording.json new file mode 100644 index 00000000..020f2515 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/benchmarks/recordings/appworld/recording.json @@ -0,0 +1,6 @@ +{ + "benchmark": "appworld", + "task_id": "f3f60f0_3", + "subset": "test_normal", + "expected_score": 1.0 +} diff --git a/labs/AgentStream/exgentic/tests/benchmarks/recordings/appworld/results.json b/labs/AgentStream/exgentic/tests/benchmarks/recordings/appworld/results.json new file mode 100644 index 00000000..fd836d88 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/benchmarks/recordings/appworld/results.json @@ -0,0 +1,79 @@ +{ + "session_id": "9e993ba4", + "success": true, + "score": 1.0, + "is_finished": true, + "status": "success", + "steps": 31, + "action_count": 31, + "invalid_action_count": 7, + "agent_cost": 4.379010000000001, + "benchmark_cost": 0.0, + "execution_time": 92.7417664527893, + "details": { + "score": 1.0, + "success": true, + "is_finished": true, + "session_metrics": { + "pass_percentage": 100.0, + "pass_count": 8, + "fail_count": 0, + "num_tests": 8, + "difficulty": 1, + "success": true + }, + "session_metadata": { + "test_tracker": { + "success": true, + "difficulty": 1, + "num_tests": 8, + "passes": [ + { + "requirement": "assert answers match.", + "label": "no_op_fail" + }, + { + "requirement": "assert model changes match spotify.Song, spotify.SongLike, spotify.Album, spotify.AlbumLike.", + "label": "no_op_fail" + }, + { + "requirement": "assert all newly liked songs are in my library via models.changed_records", + "label": "no_op_fail" + }, + { + "requirement": "assure none of the updated or removed song likes are from outside of song library.", + "label": "no_op_pass" + }, + { + "requirement": "assure everything in main_user's song library is liked now.", + "label": "no_op_fail" + }, + { + "requirement": "assert all newly liked albums are in my library via models.changed_records", + "label": "no_op_fail" + }, + { + "requirement": "assure none of the updated or removed album likes are from outside of album library.", + "label": "no_op_pass" + }, + { + "requirement": "assure everything in main_user's album library is liked now.", + "label": "no_op_fail" + } + ], + "failures": [] + } + } + }, + "cost_reports": { + "agent": { + "model_name": "openai/aws/claude-opus-4-5", + "total_cost": 4.379010000000001 + }, + "benchmark": { + "model_name": "", + "total_cost": 0 + } + }, + "task_id": "f3f60f0_3" +} diff --git a/labs/AgentStream/exgentic/tests/benchmarks/recordings/appworld/session.json b/labs/AgentStream/exgentic/tests/benchmarks/recordings/appworld/session.json new file mode 100644 index 00000000..a1eb8440 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/benchmarks/recordings/appworld/session.json @@ -0,0 +1,17009 @@ +{ + "run_id": "0c890a5dde8c", + "session_id": "9e993ba4", + "task": "Task from supervisor:\nLike all the songs and albums in my Spotify song and album library, respectively, that I have not liked yet.", + "context": { + "policy": "This environment provides a set of applications, each exposing a predefined set of APIs that may be used to perform tasks on behalf of the supervisor. The applications include: supervisor, amazon, phone, file_system, spotify, venmo, gmail, splitwise, simple_note, todoist.\n The available applications and their APIs are fixed for the task.\n\nSupervisor account credentials (such as emails, usernames, and passwords) are available through the supervisor application’s APIs and are accessed from there when required.\n\nIf an application requires an access token to perform authenticated operations, the access token is obtained by calling that application's authentication/login API using the credentials retrieved from the supervisor application. Access tokens are not provided by the supervisor application.\n\nReferences to people (e.g., friends, family, roommates) correspond to entries in the phone_contacts application.\nReferences to files or storage correspond to the file_system application, not the local machine filesystem.\n\nTime-based instructions (e.g., 'this month', 'yesterday') are interpreted with full calendar boundary ranges.\nIf an API returns paginated results, all pages constitute the complete result.\n\nThe environment consists only of the provided applications and their documented APIs and parameters. No additional endpoints, methods, arguments, or capabilities are assumed beyond those explicitly defined.\n\nWhen task execution is finished, the designated task-completion API is used to signal completion. If the task requires a final answer value, the answer is returned through that completion API. If the task cannot be completed using the available applications and APIs, the task may be marked as failed.", + "supervisor": { + "first_name": "Caitlin", + "last_name": "Burch", + "email": "caiburc@gmail.com", + "phone_number": "5896230193" + }, + "datetime": "2023-05-18T12:00:00" + }, + "actions": [ + { + "name": "finish", + "description": "Mark the currently active task as complete with the given answer.", + "is_finish": true, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "answer": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "integer" + }, + { + "type": "string" + } + ], + "default": null, + "description": "The answer to the task instruction. Pass it if and only if the task requests an answer, e.g., 'How many songs are in the Spotify queue?'. The answer must be concise, such as a number, a yes/no, or a specific entity, name, etc, e.g., '10' in this case. If the task is not a question, e.g., 'Start my Spotify music player.', the answer must be left to the default value, i.e., None.", + "title": "Answer" + }, + "status": { + "default": "success", + "description": "Mark the task complete.", + "enum": [ + "success", + "fail" + ], + "title": "Status", + "type": "string" + } + }, + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "supervisor__show_profile", + "description": "Show your supervisor's profile information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": {}, + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "supervisor__show_addresses", + "description": "Show your supervisor's addresses.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": {}, + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "supervisor__show_payment_cards", + "description": "Show your supervisor's payment_cards.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": {}, + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "supervisor__show_account_passwords", + "description": "Show your supervisor's app account passwords.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": {}, + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__show_account", + "description": "Show your account information. Unlike show_profile, this includes private information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__signup", + "description": "Sign up to create account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "first_name": { + "description": "Your first name", + "minLength": 1, + "title": "First Name", + "type": "string" + }, + "last_name": { + "description": "Your last name", + "minLength": 1, + "title": "Last Name", + "type": "string" + }, + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + }, + "password": { + "description": "Your password.", + "minLength": 5, + "title": "Password", + "type": "string" + } + }, + "required": [ + "first_name", + "last_name", + "email", + "password" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__delete_account", + "description": "Delete your account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__update_account_name", + "description": "Update your first or last name in the account profile.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "first_name": { + "default": null, + "description": "Your updated first name.", + "minLength": 1, + "title": "First Name", + "type": "string" + }, + "last_name": { + "default": null, + "description": "Your updated last name.", + "minLength": 1, + "title": "Last Name", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__login", + "description": "Login to your account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "username": { + "description": "Your account email.", + "title": "Username", + "type": "string" + }, + "password": { + "description": "Your account password.", + "title": "Password", + "type": "string" + } + }, + "required": [ + "username", + "password" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__logout", + "description": "Logout from your account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__send_verification_code", + "description": "Send account verification code to your email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + } + }, + "required": [ + "email" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__verify_account", + "description": "Verify your account using the verification code sent to your email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + }, + "verification_code": { + "description": "The verification code sent to your email address.", + "title": "Verification Code", + "type": "string" + } + }, + "required": [ + "email", + "verification_code" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__send_password_reset_code", + "description": "Send password reset code to your email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + } + }, + "required": [ + "email" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__reset_password", + "description": "Reset your password using the password reset code sent to your email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + }, + "password_reset_code": { + "description": "The password reset code sent to your email address.", + "title": "Password Reset Code", + "type": "string" + }, + "new_password": { + "description": "Your new password.", + "minLength": 5, + "title": "New Password", + "type": "string" + } + }, + "required": [ + "email", + "password_reset_code", + "new_password" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__show_profile", + "description": "Show public profile information of a user.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "default": null, + "description": "Email of the person you want to see the profile information of.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + } + }, + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__show_product", + "description": "Show product information based on its ID.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "product_id": { + "description": "The ID of the product.", + "title": "Product Id", + "type": "integer" + } + }, + "required": [ + "product_id" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__show_recommended_products", + "description": "Show products recommended for you.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__show_browsing_history", + "description": "Show products in your browsing history.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__clear_browsing_history", + "description": "Clear your browsing history.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__add_product_to_browsing_history", + "description": "Add a product to your browsing history.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "product_id": { + "description": "ID of the product to add to browsing history.", + "title": "Product Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "product_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__remove_product_from_browsing_history", + "description": "Remove a product from your browsing history.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "product_id": { + "description": "ID of the product to remove from browsing history.", + "title": "Product Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "product_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__update_browsing_history_tracking", + "description": "Update browsing history tracking preference.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "track_browsing_history": { + "description": "Whether to track browsing history.", + "title": "Track Browsing History", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "track_browsing_history", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__show_last_product_purchase", + "description": "Show your last purchase information of a product with the given ID or its size and color variations.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "product_id": { + "description": "The ID of the product.", + "title": "Product Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "product_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__show_product_rating_distribution", + "description": "Show the rating distribution of a product.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "product_id": { + "description": "The ID of the product.", + "title": "Product Id", + "type": "integer" + } + }, + "required": [ + "product_id" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__search_sellers", + "description": "Search for sellers with a query.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "The search query string.", + "title": "Query", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + } + }, + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__show_seller", + "description": "Show a detailed information about the seller.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "seller_id": { + "description": "The ID of the seller.", + "title": "Seller Id", + "type": "integer" + } + }, + "required": [ + "seller_id" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__search_product_types", + "description": "Search product types present in the database.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "The search query string.", + "title": "Query", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 9223372036854775807, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + } + }, + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__show_product_feature_choices", + "description": "Show the choices of colors, relative sizes and sellers aggregated over all products of the given product type. Because it's an aggregation, the choices may not be available for all products. If product type is not passed, it will return the choices for all products in the database.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "product_type": { + "default": null, + "description": "The product type to obtain feature choices for.", + "title": "Product Type", + "type": "string" + } + }, + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__search_products", + "description": "Search for products with a query and various filtering criteria.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "The search query string.", + "title": "Query", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "product_type": { + "default": null, + "description": "The type of product to search for.", + "title": "Product Type", + "type": "string" + }, + "color": { + "default": null, + "description": "The color of product to search for.", + "title": "Color", + "type": "string" + }, + "relative_size": { + "default": null, + "description": "The relative size of product to search for.", + "enum": [ + "extra-small", + "small", + "medium", + "large", + "extra-large" + ], + "title": "Relative Size", + "type": "string" + }, + "min_price": { + "default": 0.0, + "description": "The minimum price for search results.", + "minimum": 0.0, + "title": "Min Price", + "type": "number" + }, + "max_price": { + "default": 9.223372036854776e+18, + "description": "The maximum price for search results.", + "minimum": 0.0, + "title": "Max Price", + "type": "number" + }, + "min_product_rating": { + "default": 0.0, + "description": "The minimum product rating for search results.", + "maximum": 5.0, + "minimum": 0.0, + "title": "Min Product Rating", + "type": "number" + }, + "max_product_rating": { + "default": 5.0, + "description": "The maximum product rating for search results.", + "maximum": 5.0, + "minimum": 0.0, + "title": "Max Product Rating", + "type": "number" + }, + "min_seller_rating": { + "default": 0.0, + "description": "The minimum seller rating for search results.", + "maximum": 5.0, + "minimum": 0.0, + "title": "Min Seller Rating", + "type": "number" + }, + "max_seller_rating": { + "default": 5.0, + "description": "The maximum seller rating for search results.", + "maximum": 5.0, + "minimum": 0.0, + "title": "Max Seller Rating", + "type": "number" + }, + "seller_id": { + "default": null, + "description": "ID of the seller to search for.", + "title": "Seller Id", + "type": "integer" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the products by prefixed with +/- to reflect ascending/descending. Valid attributes: rating, price and delivery_days. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page.", + "title": "Sort By", + "type": "string" + } + }, + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__show_cart", + "description": "show your cart.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__clear_cart", + "description": "Clear your cart.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__add_product_to_cart", + "description": "Add product by id and quantities to your cart.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "quantity": { + "default": 1, + "description": "The quantity of the product to be added to the cart.", + "minimum": 1, + "title": "Quantity", + "type": "integer" + }, + "clear_cart_first": { + "default": false, + "description": "If true, the cart will be cleared before adding the product to the cart.", + "title": "Clear Cart First", + "type": "boolean" + }, + "product_id": { + "description": "ID of the product to be added to the cart.", + "title": "Product Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "product_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__update_product_quantity_in_cart", + "description": "Update product quantity in the user cart.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "quantity": { + "description": "The new quantity of the product to be updated in the cart.", + "minimum": 0, + "title": "Quantity", + "type": "integer" + }, + "product_id": { + "description": "ID of the product to be updated in the cart.", + "title": "Product Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "quantity", + "product_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__delete_product_from_cart", + "description": "Remove a product from your cart.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "product_id": { + "description": "ID of the product to be deleted from the cart.", + "title": "Product Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "product_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__apply_promo_code_to_cart", + "description": "Apply a promo code to your cart.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "promo_code": { + "description": "The promo code to be applied to the cart.", + "title": "Promo Code", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "promo_code", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__remove_promo_code_from_cart", + "description": "Remove a promo code from your cart.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__show_wish_list", + "description": "Get list of products in your wishlist.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__clear_wish_list", + "description": "Clear wish list.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__add_product_to_wish_list", + "description": "Add product by id and quantities to your wish list.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "quantity": { + "default": 1, + "description": "The quantity of the product to be added to the wish list.", + "minimum": 1, + "title": "Quantity", + "type": "integer" + }, + "clear_wish_list_first": { + "default": false, + "description": "If true, the wish list will be cleared before adding the product to the wish list.", + "title": "Clear Wish List First", + "type": "boolean" + }, + "product_id": { + "description": "ID of the product to be added to the wish list.", + "title": "Product Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "product_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__delete_product_from_wish_list", + "description": "Remove product from the user wish list.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "product_id": { + "description": "ID of the product to be deleted from the wish list.", + "title": "Product Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "product_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__update_product_quantity_in_wish_list", + "description": "Update product quantity in the user wish_list.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "quantity": { + "description": "The new quantity of the product to be updated in the wish list.", + "minimum": 0, + "title": "Quantity", + "type": "integer" + }, + "product_id": { + "description": "ID of the product being updated in the wish list.", + "title": "Product Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "quantity", + "product_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__move_product_from_cart_to_wish_list", + "description": "Move product from the cart to the wish list.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "quantity": { + "default": 1, + "description": "Quantity of the product to move.", + "minimum": 1, + "title": "Quantity", + "type": "integer" + }, + "product_id": { + "description": "ID of the product to be move", + "title": "Product Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "product_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__move_product_from_wish_list_to_cart", + "description": "Move product from the wish list to the cart.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "quantity": { + "default": 1, + "description": "Quantity of product to move to cart.", + "minimum": 1, + "title": "Quantity", + "type": "integer" + }, + "product_id": { + "description": "ID of the product to move to cart.", + "title": "Product Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "product_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__add_gift_wrapping_to_product", + "description": "Add gift wrapping to a product in your cart. If the product is already set to be gift wrapped, its quantity will be updated.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "quantity": { + "default": 1, + "description": "Quantity of the product to be gift wrapped.", + "minimum": 1, + "title": "Quantity", + "type": "integer" + }, + "product_id": { + "description": "ID of the product in your cart to be gift wrapped.", + "title": "Product Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "product_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__remove_gift_wrapping_from_product", + "description": "Remove gift wrapping from a product in your cart.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "product_id": { + "description": "ID of the product in your cart to be gift wrapped.", + "title": "Product Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "product_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__show_orders", + "description": "Show or search your past orders", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "Search query string.", + "title": "Query", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the orders by prefixed with +/- to reflect ascending/descending. Valid attributes: created_at. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page. If both query and sort_by are not given, null, or empty, sort_by will default to -created_at.", + "title": "Sort By", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__place_order", + "description": "Place an order for all the items in your cart.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "payment_card_id": { + "description": "ID of the payment card to use for this order.", + "title": "Payment Card Id", + "type": "integer" + }, + "address_id": { + "description": "ID of the address used for shipping this order.", + "title": "Address Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "payment_card_id", + "address_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__show_product_purchases", + "description": "Show products you have purchased in the past.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__show_order", + "description": "Get details of a past order.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "order_id": { + "description": "ID of the order to be shown.", + "title": "Order Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "order_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__download_order_receipt", + "description": "Download the receipt of a past order.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "order_id": { + "description": "ID of the order to download the receipt for.", + "title": "Order Id", + "type": "integer" + }, + "file_system_access_token": { + "description": "Access token obtained from file_system app login.", + "title": "File System Access Token", + "type": "string" + }, + "download_to_file_path": { + "default": null, + "description": "The file path to download the receipt file to in file system app. Path can be absolute, starting with '/', or relative to the user's home directory, starting with '~/'. If not passed, it will be saved in your ~/downloads directory.", + "title": "Download To File Path", + "type": "string" + }, + "overwrite": { + "default": false, + "description": "Whether to overwrite the file if it already exists.", + "title": "Overwrite", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "order_id", + "file_system_access_token", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__show_payment_cards", + "description": "Get a list of your payment_cards.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__add_payment_card", + "description": "Add a new payment card.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "card_name": { + "description": "Name of the payment card.", + "minLength": 1, + "title": "Card Name", + "type": "string" + }, + "owner_name": { + "description": "Full name of the owner of the payment card.", + "minLength": 1, + "title": "Owner Name", + "type": "string" + }, + "card_number": { + "description": "16-digit card number.", + "exclusiveMaximum": 10000000000000000, + "minimum": 1000000000000000, + "title": "Card Number", + "type": "integer" + }, + "expiry_year": { + "description": "Expiration year of the payment card.", + "title": "Expiry Year", + "type": "integer" + }, + "expiry_month": { + "description": "Expiration month of the payment card.", + "maximum": 12, + "minimum": 1, + "title": "Expiry Month", + "type": "integer" + }, + "cvv_number": { + "description": "A 3-digit CVV number of the payment card.", + "exclusiveMaximum": 1000, + "minimum": 100, + "title": "Cvv Number", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "card_name", + "owner_name", + "card_number", + "expiry_year", + "expiry_month", + "cvv_number", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__show_payment_card", + "description": "Get details of a payment card.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "payment_card_id": { + "description": "ID of the payment card to be shown.", + "title": "Payment Card Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "payment_card_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__update_payment_card", + "description": "Update payment card information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "card_name": { + "description": "Name of the payment card to update.", + "minLength": 1, + "title": "Card Name", + "type": "string" + }, + "payment_card_id": { + "description": "ID of the payment card to update.", + "title": "Payment Card Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "card_name", + "payment_card_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__delete_payment_card", + "description": "Delete payment card information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "payment_card_id": { + "description": "ID of the payment card to be deleted.", + "title": "Payment Card Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "payment_card_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__show_addresses", + "description": "Get a list of your addresses.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__add_address", + "description": "Add a new address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "name": { + "description": "Name of the address, for example 'Home' or 'Work'.", + "minLength": 1, + "title": "Name", + "type": "string" + }, + "street_address": { + "description": "Street address line.", + "minLength": 1, + "title": "Street Address", + "type": "string" + }, + "city": { + "description": "Name of the city.", + "minLength": 1, + "title": "City", + "type": "string" + }, + "state": { + "description": "Name of the state.", + "minLength": 1, + "title": "State", + "type": "string" + }, + "country": { + "description": "Name of the country.", + "minLength": 1, + "title": "Country", + "type": "string" + }, + "zip_code": { + "description": "5-digit zip code of the address.", + "exclusiveMaximum": 100000, + "minimum": 10000, + "title": "Zip Code", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "name", + "street_address", + "city", + "state", + "country", + "zip_code", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__update_address", + "description": "Update address information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "name": { + "default": null, + "description": "New name for the address", + "minLength": 1, + "title": "Name", + "type": "string" + }, + "street_address": { + "default": null, + "description": "New street address line.", + "minLength": 1, + "title": "Street Address", + "type": "string" + }, + "city": { + "default": null, + "description": "New city name for the address", + "minLength": 1, + "title": "City", + "type": "string" + }, + "state": { + "default": null, + "description": "New state name for the address", + "minLength": 1, + "title": "State", + "type": "string" + }, + "country": { + "default": null, + "description": "New country name for the address", + "minLength": 1, + "title": "Country", + "type": "string" + }, + "zip_code": { + "default": null, + "description": "New ZIP code for the address", + "maximum": 99999, + "minimum": 10000, + "title": "Zip Code", + "type": "integer" + }, + "address_id": { + "description": "ID of the address to update", + "title": "Address Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "address_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__delete_address", + "description": "Delete address information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "address_id": { + "description": "ID of the address to be deleted.", + "title": "Address Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "address_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__show_product_reviews", + "description": "Search or show a list of product reviews.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "product_id": { + "description": "ID of the product to show reviews for.", + "title": "Product Id", + "type": "integer" + }, + "query": { + "default": "", + "description": "The search query string.", + "title": "Query", + "type": "string" + }, + "user_email": { + "default": null, + "description": "Email of the user to filter reviews by.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "User Email", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "min_rating": { + "default": 1, + "description": "The minimum rating for a review.", + "maximum": 5, + "minimum": 1, + "title": "Min Rating", + "type": "integer" + }, + "max_rating": { + "default": 5, + "description": "The maximum rating for a review.", + "maximum": 5, + "minimum": 1, + "title": "Max Rating", + "type": "integer" + }, + "is_verified": { + "default": null, + "description": "Filter reviews by whether they from a verified purchaser or not.", + "title": "Is Verified", + "type": "boolean" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the product reviews by prefixed with +/- to reflect ascending/descending. Valid attributes: rating and created_at. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page.", + "title": "Sort By", + "type": "string" + } + }, + "required": [ + "product_id" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__write_product_review", + "description": "Write a product review.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "rating": { + "description": "Product rating for this review.", + "maximum": 5, + "minimum": 1, + "title": "Rating", + "type": "integer" + }, + "title": { + "default": "", + "description": "Title of the review.", + "title": "Title", + "type": "string" + }, + "text": { + "default": "", + "description": "Text content of the review.", + "title": "Text", + "type": "string" + }, + "product_id": { + "description": "ID of the product to review.", + "title": "Product Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "rating", + "product_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__update_product_review", + "description": "Update a product review.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "rating": { + "default": null, + "description": "Product rating for this review.", + "maximum": 5, + "minimum": 1, + "title": "Rating", + "type": "integer" + }, + "title": { + "default": null, + "description": "Title of the review.", + "title": "Title", + "type": "string" + }, + "text": { + "default": null, + "description": "Text content of the review.", + "title": "Text", + "type": "string" + }, + "review_id": { + "description": "ID of the product review.", + "title": "Review Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "review_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__delete_product_review", + "description": "Delete a product review.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "review_id": { + "description": "ID of the product review.", + "title": "Review Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "review_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__show_product_questions", + "description": "Search or show a list of product questions.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "product_id": { + "description": "ID of the product to show questions for.", + "title": "Product Id", + "type": "integer" + }, + "query": { + "default": "", + "description": "The search query string.", + "title": "Query", + "type": "string" + }, + "user_email": { + "default": null, + "description": "Email of the user who posted the question", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "User Email", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the questions by prefixed with +/- to reflect ascending/descending. Valid attributes: created_at. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page.", + "title": "Sort By", + "type": "string" + } + }, + "required": [ + "product_id" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__write_product_question", + "description": "Post a question about a product.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "question": { + "description": "Question to be written for the product.", + "minLength": 1, + "title": "Question", + "type": "string" + }, + "product_id": { + "description": "ID of the product to ask a question about.", + "title": "Product Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "question", + "product_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__show_product_question_answers", + "description": "Search or show a list of answers to a product question.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "question_id": { + "description": "ID of the product question.", + "title": "Question Id", + "type": "integer" + }, + "query": { + "default": "", + "description": "The search query string", + "title": "Query", + "type": "string" + }, + "user_email": { + "default": null, + "description": "Email of the user who posted the answer", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "User Email", + "type": "string" + }, + "is_verified": { + "default": null, + "description": "Filter answers by whether they from a verified purchaser or not.", + "title": "Is Verified", + "type": "boolean" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the question answers by prefixed with +/- to reflect ascending/descending. Valid attributes: created_at. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page.", + "title": "Sort By", + "type": "string" + } + }, + "required": [ + "question_id" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__write_product_question_answer", + "description": "Write a answer to a product question.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "answer": { + "description": "Answer to the question.", + "minLength": 1, + "title": "Answer", + "type": "string" + }, + "question_id": { + "description": "ID of the product question this is an answer to.", + "title": "Question Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "answer", + "question_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__update_product_question", + "description": "Update a product question.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "question": { + "default": null, + "description": "The updated question.", + "minLength": 1, + "title": "Question", + "type": "string" + }, + "question_id": { + "description": "ID of the product question to update.", + "title": "Question Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "question_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__delete_product_question", + "description": "Delete a product question.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "question_id": { + "description": "ID of the question to delete.", + "title": "Question Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "question_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__update_product_question_answer", + "description": "Update answer to a product question.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "answer": { + "default": null, + "description": "The updated answer for the question.", + "minLength": 1, + "title": "Answer", + "type": "string" + }, + "question_answer_id": { + "description": "ID of the question answer to update.", + "title": "Question Answer Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "question_answer_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__delete_product_question_answer", + "description": "Delete a answer to a product question.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "question_answer_id": { + "description": "ID of the question answer to delete.", + "title": "Question Answer Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "question_answer_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__show_returns", + "description": "Get a list of your product returns.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "order_id": { + "default": null, + "description": "ID of the order to filter returns by.", + "title": "Order Id", + "type": "integer" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "sort_by": { + "default": "-initiated_at", + "description": "The attribute to sort the product returns by prefixed with +/- to reflect ascending/descending. Valid attributes: quantity, initiated_at and returned_at.", + "title": "Sort By", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__initiate_return", + "description": "Initiate a product return.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "order_id": { + "description": "ID of the order to be returned.", + "title": "Order Id", + "type": "integer" + }, + "product_id": { + "description": "ID of the product to be returned.", + "title": "Product Id", + "type": "integer" + }, + "deliverer_id": { + "description": "ID of the deliverer assigned to the return.", + "title": "Deliverer Id", + "type": "integer" + }, + "quantity": { + "description": "Quantity of the product to be returned.", + "minimum": 1, + "title": "Quantity", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "order_id", + "product_id", + "deliverer_id", + "quantity", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__show_return", + "description": "Show product return status.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "return_id": { + "description": "ID of the product return.", + "title": "Return Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "return_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__show_return_deliverers", + "description": "Get a list of product return deliverers.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": {}, + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__show_prime_plans", + "description": "Show information about prime plans available. Delivery fee is waived for prime members.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": {}, + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__subscribe_prime", + "description": "Subscribe to prime membership.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "payment_card_id": { + "description": "ID of the payment card to use for buy prime subscription.", + "title": "Payment Card Id", + "type": "integer" + }, + "duration": { + "description": "Duration of the prime subscription.", + "enum": [ + "monthly", + "yearly" + ], + "title": "Duration", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "payment_card_id", + "duration", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__show_prime_subscriptions", + "description": "Show your prime subscription history.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "amazon__download_prime_subscription_receipt", + "description": "Download the receipt for a prime subscription.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "prime_subscription_id": { + "description": "ID of the prime subscription to download the receipt for.", + "title": "Prime Subscription Id", + "type": "integer" + }, + "file_system_access_token": { + "description": "Access token obtained from file_system app login.", + "title": "File System Access Token", + "type": "string" + }, + "download_to_file_path": { + "default": null, + "description": "The file path to download the receipt file to in file system app. Path can be absolute, starting with '/', or relative to the user's home directory, starting with '~/'. If not passed, it will be saved in your ~/downloads directory.", + "title": "Download To File Path", + "type": "string" + }, + "overwrite": { + "default": false, + "description": "Whether to overwrite the file if it already exists.", + "title": "Overwrite", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from amazon app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "prime_subscription_id", + "file_system_access_token", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "phone__show_account", + "description": "Show your account information. Unlike show_profile, this includes private information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from phone app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "phone__signup", + "description": "Sign up to create account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "first_name": { + "description": "Your first name", + "minLength": 1, + "title": "First Name", + "type": "string" + }, + "last_name": { + "description": "Your last name", + "minLength": 1, + "title": "Last Name", + "type": "string" + }, + "phone_number": { + "description": "Your 10-digit phone number.", + "maxLength": 10, + "minLength": 10, + "title": "Phone Number", + "type": "string" + }, + "password": { + "description": "Your password.", + "minLength": 5, + "title": "Password", + "type": "string" + } + }, + "required": [ + "first_name", + "last_name", + "phone_number", + "password" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "phone__delete_account", + "description": "Delete your account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from phone app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "phone__update_account_name", + "description": "Update your first or last name in the account profile.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "first_name": { + "default": null, + "description": "Your updated first name.", + "minLength": 1, + "title": "First Name", + "type": "string" + }, + "last_name": { + "default": null, + "description": "Your updated last name.", + "minLength": 1, + "title": "Last Name", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from phone app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "phone__login", + "description": "Login to your account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "username": { + "description": "Your account phone_number.", + "title": "Username", + "type": "string" + }, + "password": { + "description": "Your account password.", + "title": "Password", + "type": "string" + } + }, + "required": [ + "username", + "password" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "phone__logout", + "description": "Logout from your account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from phone app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "phone__send_password_reset_code", + "description": "Send password reset code to your phone number.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "phone_number": { + "description": "Your phone number.", + "title": "Phone Number", + "type": "string" + } + }, + "required": [ + "phone_number" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "phone__reset_password", + "description": "Reset your password using the password reset code sent to your email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "phone_number": { + "description": "Your phone number.", + "title": "Phone Number", + "type": "string" + }, + "password_reset_code": { + "description": "The password reset code sent to your phone number.", + "title": "Password Reset Code", + "type": "string" + }, + "new_password": { + "description": "Your new password.", + "minLength": 5, + "title": "New Password", + "type": "string" + } + }, + "required": [ + "phone_number", + "password_reset_code", + "new_password" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "phone__show_profile", + "description": "Show public profile information of a user.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "phone_number": { + "default": null, + "description": "Phone number of the person you want to see the profile information of.", + "title": "Phone Number", + "type": "string" + } + }, + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "phone__show_contact_relationships", + "description": "Get a list of all relationships available in your contact book.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from phone app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "phone__search_contacts", + "description": "Search your contact book for relatives' information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "Search query for the contacts list.", + "title": "Query", + "type": "string" + }, + "relationship": { + "default": null, + "description": "Relationship with the person in the contacts list to filter by.", + "title": "Relationship", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from phone app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "phone__add_contact", + "description": "Add a new contact.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "first_name": { + "description": "First name of the contact.", + "title": "First Name", + "type": "string" + }, + "last_name": { + "description": "Last name of the contact.", + "title": "Last Name", + "type": "string" + }, + "phone_number": { + "default": null, + "description": "Phone number of the contact.", + "title": "Phone Number", + "type": "string" + }, + "email": { + "default": null, + "description": "Email of the contact.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + }, + "relationships": { + "default": null, + "description": "Relationship with the contact.", + "items": { + "type": "string" + }, + "title": "Relationships", + "type": "array" + }, + "birthday": { + "default": null, + "description": "Birthday of the contact in YYYY-MM-DD format.", + "title": "Birthday", + "type": "string" + }, + "home_address": { + "default": null, + "description": "Home address of the contact.", + "title": "Home Address", + "type": "string" + }, + "work_address": { + "default": null, + "description": "Work address of the contact.", + "title": "Work Address", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from phone app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "first_name", + "last_name", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "phone__update_contact", + "description": "Update contact information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "first_name": { + "default": null, + "description": "Updated first name of the contact.", + "title": "First Name", + "type": "string" + }, + "last_name": { + "default": null, + "description": "Updated last name of the contact.", + "title": "Last Name", + "type": "string" + }, + "phone_number": { + "default": null, + "description": "Updated phone number of the contact.", + "title": "Phone Number", + "type": "string" + }, + "email": { + "default": null, + "description": "Updated email of the contact.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + }, + "relationships": { + "default": null, + "description": "Updated relationship with the contact.", + "items": { + "type": "string" + }, + "title": "Relationships", + "type": "array" + }, + "birthday": { + "default": null, + "description": "Updated birthday of the contact in YYYY-MM-DD format.", + "title": "Birthday", + "type": "string" + }, + "home_address": { + "default": null, + "description": "Updated home address of the contact.", + "title": "Home Address", + "type": "string" + }, + "work_address": { + "default": null, + "description": "Updated work address of the contact.", + "title": "Work Address", + "type": "string" + }, + "contact_id": { + "description": "ID of the contact to update.", + "title": "Contact Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from phone app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "contact_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "phone__delete_contact", + "description": "Delete contact information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "contact_id": { + "description": "ID of the contact to be deleted.", + "title": "Contact Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from phone app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "contact_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "phone__show_text_message_window", + "description": "Show text messages with a contact around a given date and time.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "phone_number": { + "description": "The phone number of the contact to show messages with.", + "title": "Phone Number", + "type": "string" + }, + "min_datetime": { + "default": "1500-01-01|00:00:00", + "description": "The minimum datetime to show messages on or after in YYYY-MM-DD|HH:MM:SS format.", + "title": "Min Datetime", + "type": "string" + }, + "max_datetime": { + "default": "3000-01-01|00:00:00", + "description": "The maximum datetime to show messages on or before in YYYY-MM-DD|HH:MM:SS format.", + "title": "Max Datetime", + "type": "string" + }, + "pagination_order": { + "default": "descending", + "description": "If set to ascending, as page_index increases, the results will have newer messages. If set to descending, as page_index increases, the results will have older messages. The messages within each page will always be oldest to newest.", + "enum": [ + "ascending", + "descending" + ], + "title": "Pagination Order", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from phone app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "phone_number", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "phone__search_text_messages", + "description": "Show or search your text messages.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "The search query string.", + "title": "Query", + "type": "string" + }, + "phone_number": { + "default": null, + "description": "The phone number of the contact to show messages with.", + "title": "Phone Number", + "type": "string" + }, + "only_latest_per_contact": { + "default": false, + "description": "If set to true, only the latest message from each contact will be shown.", + "title": "Only Latest Per Contact", + "type": "boolean" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the messages by prefixed with +/- to reflect ascending/descending. Valid attributes: created_at. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page. If both query and sort_by are not given, null, or empty, sort_by will default to -created_at.", + "title": "Sort By", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from phone app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "phone__show_text_message", + "description": "Show text message details.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "text_message_id": { + "description": "ID of the text message to show.", + "title": "Text Message Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from phone app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "text_message_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "phone__delete_text_message", + "description": "Delete a text message.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "text_message_id": { + "description": "ID of the text message to be deleted.", + "title": "Text Message Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from phone app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "text_message_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "phone__send_text_message", + "description": "Send a text message on the given phone number.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "message": { + "description": "The content of the text message.", + "minLength": 1, + "title": "Message", + "type": "string" + }, + "phone_number": { + "description": "The phone number of the contact to send the message to.", + "title": "Phone Number", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from phone app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "message", + "phone_number", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "phone__show_alarms", + "description": "Get a list of alarms.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from phone app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "phone__create_alarm", + "description": "Create a new alarm.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "time": { + "description": "The time of the alarm in HH:MM format.", + "title": "Time", + "type": "string" + }, + "repeat_days": { + "default": null, + "description": "Days on which the alarm repeats.", + "items": { + "enum": [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday" + ], + "type": "string" + }, + "title": "Repeat Days", + "type": "array" + }, + "label": { + "default": null, + "description": "The label for the alarm.", + "title": "Label", + "type": "string" + }, + "enabled": { + "default": true, + "description": "Whether the alarm is enabled or not.", + "title": "Enabled", + "type": "boolean" + }, + "snooze_minutes": { + "default": 15, + "description": "The duration of snooze in minutes. Use 0 for no snooze.", + "minimum": 0, + "title": "Snooze Minutes", + "type": "integer" + }, + "vibration": { + "default": true, + "description": "Whether the alarm should vibrate or not.", + "title": "Vibration", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from phone app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "time", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "phone__show_alarm", + "description": "Show alarm details.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "alarm_id": { + "description": "ID of the alarm to show.", + "title": "Alarm Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from phone app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "alarm_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "phone__update_alarm", + "description": "Update an alarm's settings.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "time": { + "default": null, + "description": "The updated time of the alarm in HH:MM format.", + "title": "Time", + "type": "string" + }, + "repeat_days": { + "default": null, + "description": "The updated days on which the alarm should repeat.", + "items": { + "enum": [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday" + ], + "type": "string" + }, + "title": "Repeat Days", + "type": "array" + }, + "label": { + "default": null, + "description": "The updated label for the alarm.", + "title": "Label", + "type": "string" + }, + "enabled": { + "default": null, + "description": "Whether the alarm is enabled or not.", + "title": "Enabled", + "type": "boolean" + }, + "snooze_minutes": { + "default": null, + "description": "The updated duration of snooze in minutes. Use 0 for no snooze.", + "minimum": 0, + "title": "Snooze Minutes", + "type": "integer" + }, + "vibration": { + "default": null, + "description": "Whether the alarm should vibrate or not.", + "title": "Vibration", + "type": "boolean" + }, + "alarm_id": { + "description": "ID of the alarm to be updated.", + "title": "Alarm Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from phone app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "alarm_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "phone__delete_alarm", + "description": "Delete an alarm.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "alarm_id": { + "description": "ID of the alarm to delete.", + "title": "Alarm Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from phone app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "alarm_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "phone__show_voice_message_window", + "description": "Show voice messages with a contact around a given date and time.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "phone_number": { + "description": "The phone number of the contact to show messages with.", + "title": "Phone Number", + "type": "string" + }, + "min_datetime": { + "default": "1500-01-01|00:00:00", + "description": "The minimum datetime to show messages on or after in YYYY-MM-DD|HH:MM:SS format.", + "title": "Min Datetime", + "type": "string" + }, + "max_datetime": { + "default": "3000-01-01|00:00:00", + "description": "The maximum datetime to show messages on or before in YYYY-MM-DD|HH:MM:SS format.", + "title": "Max Datetime", + "type": "string" + }, + "pagination_order": { + "default": "descending", + "description": "If set to ascending, as page_index increases, the results will have newer messages. If set to descending, as page_index increases, the results will have older messages. The messages within each page will always be oldest to newest.", + "enum": [ + "ascending", + "descending" + ], + "title": "Pagination Order", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from phone app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "phone_number", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "phone__search_voice_messages", + "description": "Show or search text voice_messages between the user and a contact.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "The search query string.", + "title": "Query", + "type": "string" + }, + "phone_number": { + "default": null, + "description": "The phone number of the contact to show voice_messages with.", + "title": "Phone Number", + "type": "string" + }, + "only_latest_per_contact": { + "default": false, + "description": "If set to true, only the latest message from each contact will be shown.", + "title": "Only Latest Per Contact", + "type": "boolean" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the voice messages by prefixed with +/- to reflect ascending/descending. Valid attributes: created_at. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page. If both query and sort_by are not given, null, or empty, sort_by will default to -created_at.", + "title": "Sort By", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from phone app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "phone__show_voice_message", + "description": "Show voice message details.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "voice_message_id": { + "description": "ID of the voice message to show.", + "title": "Voice Message Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from phone app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "voice_message_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "phone__delete_voice_message", + "description": "Delete a voice message.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "voice_message_id": { + "description": "The ID of the voice message to delete.", + "title": "Voice Message Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from phone app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "voice_message_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "phone__send_voice_message", + "description": "Send a voice message on the given phone number.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "message": { + "description": "The message text of the voice_message.", + "minLength": 1, + "title": "Message", + "type": "string" + }, + "phone_number": { + "description": "The phone number of the contact to send the voice message to.", + "title": "Phone Number", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from phone app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "message", + "phone_number", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "phone__get_current_date_and_time", + "description": "Show current date and time.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": {}, + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "file_system__show_account", + "description": "Show your account information. Unlike show_profile, this includes private information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from file_system app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "file_system__signup", + "description": "Sign up to create account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "first_name": { + "description": "Your first name", + "minLength": 1, + "title": "First Name", + "type": "string" + }, + "last_name": { + "description": "Your last name", + "minLength": 1, + "title": "Last Name", + "type": "string" + }, + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + }, + "password": { + "description": "Your password.", + "minLength": 5, + "title": "Password", + "type": "string" + } + }, + "required": [ + "first_name", + "last_name", + "email", + "password" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "file_system__delete_account", + "description": "Delete your account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from file_system app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "file_system__update_account_name", + "description": "Update your first or last name in the account profile.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "first_name": { + "default": null, + "description": "Your updated first name.", + "minLength": 1, + "title": "First Name", + "type": "string" + }, + "last_name": { + "default": null, + "description": "Your updated last name.", + "minLength": 1, + "title": "Last Name", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from file_system app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "file_system__login", + "description": "Login to your account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "username": { + "description": "Your account email.", + "title": "Username", + "type": "string" + }, + "password": { + "description": "Your account password.", + "title": "Password", + "type": "string" + } + }, + "required": [ + "username", + "password" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "file_system__logout", + "description": "Logout from your account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from file_system app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "file_system__send_verification_code", + "description": "Send account verification code to your email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + } + }, + "required": [ + "email" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "file_system__verify_account", + "description": "Verify your account using the verification code sent to your email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + }, + "verification_code": { + "description": "The verification code sent to your email address.", + "title": "Verification Code", + "type": "string" + } + }, + "required": [ + "email", + "verification_code" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "file_system__send_password_reset_code", + "description": "Send password reset code to your email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + } + }, + "required": [ + "email" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "file_system__reset_password", + "description": "Reset your password using the password reset code sent to your email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + }, + "password_reset_code": { + "description": "The password reset code sent to your email address.", + "title": "Password Reset Code", + "type": "string" + }, + "new_password": { + "description": "Your new password.", + "minLength": 5, + "title": "New Password", + "type": "string" + } + }, + "required": [ + "email", + "password_reset_code", + "new_password" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "file_system__show_profile", + "description": "Show public profile information of a user.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "default": null, + "description": "Email of the person you want to see the profile information of.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + } + }, + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "file_system__create_directory", + "description": "Create a directory if it does not exist, optionally recursively.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "directory_path": { + "description": "Path of the directory. Path can be absolute, starting with '/', or relative to the user's home directory, starting with '~/'.", + "title": "Directory Path", + "type": "string" + }, + "recursive": { + "default": false, + "description": "If True, it will create all parent directories recursively if they don't exist.", + "title": "Recursive", + "type": "boolean" + }, + "allow_if_exists": { + "default": true, + "description": "If True, it will not raise an error if the directory already exists.", + "title": "Allow If Exists", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from file_system app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "directory_path", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "file_system__delete_directory", + "description": "Delete a directory with its sub-directories and files.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "directory_path": { + "description": "Path of the directory. Path can be absolute, starting with '/', or relative to the user's home directory, starting with '~/'.", + "title": "Directory Path", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from file_system app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "directory_path", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "file_system__show_directory", + "description": "Show a list of files and/or sub-directories, optionally recursively, in a directory.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "directory_path": { + "default": "/", + "description": "Path of the directory. Path can be absolute, starting with '/', or relative to the user's home directory, starting with '~/'.", + "minLength": 1, + "title": "Directory Path", + "type": "string" + }, + "substring": { + "default": null, + "description": "If passed, only files and sub-directories containing the given substring (ignoring case) will be shown.", + "title": "Substring", + "type": "string" + }, + "entry_type": { + "default": "all", + "description": "Whether to show all files and sub-directories, only files, or only sub-directories.", + "enum": [ + "all", + "files", + "directories" + ], + "title": "Entry Type", + "type": "string" + }, + "recursive": { + "default": true, + "description": "Whether to show files recursively.", + "title": "Recursive", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from file_system app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "file_system__directory_exists", + "description": "Check if a directory exists.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "directory_path": { + "description": "Path of the directory. Path can be absolute, starting with '/', or relative to the user's home directory, starting with '~/'.", + "minLength": 1, + "title": "Directory Path", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from file_system app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "directory_path", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "file_system__show_file", + "description": "Show a file's content and other details, if it exists.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "file_path": { + "description": "Path of the file. Path can be absolute, starting with '/', or relative to the user's home directory, starting with '~/'.", + "title": "File Path", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from file_system app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "file_path", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "file_system__create_file", + "description": "Create a new file with the given content.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "file_path": { + "description": "Path of the file. Path can be absolute, starting with '/', or relative to the user's home directory, starting with '~/'.", + "title": "File Path", + "type": "string" + }, + "content": { + "default": "", + "description": "The content of the file.", + "title": "Content", + "type": "string" + }, + "overwrite": { + "default": false, + "description": "Whether to overwrite the file if it already exists.", + "title": "Overwrite", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from file_system app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "file_path", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "file_system__update_file", + "description": "Update a file's content.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "file_path": { + "description": "Path of the file. Path can be absolute, starting with '/', or relative to the user's home directory, starting with '~/'.", + "title": "File Path", + "type": "string" + }, + "content": { + "description": "The updated content of the file.", + "title": "Content", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from file_system app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "file_path", + "content", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "file_system__delete_file", + "description": "Delete a file.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "file_path": { + "description": "Path of the file. Path can be absolute, starting with '/', or relative to the user's home directory, starting with '~/'.", + "title": "File Path", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from file_system app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "file_path", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "file_system__file_exists", + "description": "Check if a file exists.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "file_path": { + "description": "Path of the file. Path can be absolute, starting with '/', or relative to the user's home directory, starting with '~/'.", + "minLength": 1, + "title": "File Path", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from file_system app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "file_path", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "file_system__copy_file", + "description": "Copy a file to another location.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "source_file_path": { + "description": "Path of the source file. Path can be absolute, starting with '/', or relative to the user's home directory, starting with '~/'.", + "title": "Source File Path", + "type": "string" + }, + "destination_file_path": { + "description": "Path of the destination file. Path can be absolute, starting with '/', or relative to the user's home directory, starting with '~/'.", + "title": "Destination File Path", + "type": "string" + }, + "overwrite": { + "default": false, + "description": "Whether to overwrite the file if it already exists.", + "title": "Overwrite", + "type": "boolean" + }, + "retain_dates": { + "default": false, + "description": "Whether the copied file should retain the original file's created and updated dates.", + "title": "Retain Dates", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from file_system app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "source_file_path", + "destination_file_path", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "file_system__move_file", + "description": "Move a file to another location.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "source_file_path": { + "description": "Path of the source file. Path can be absolute, starting with '/', or relative to the user's home directory, starting with '~/'.", + "title": "Source File Path", + "type": "string" + }, + "destination_file_path": { + "description": "Path of the destination file. Path can be absolute, starting with '/', or relative to the user's home directory, starting with '~/'.", + "title": "Destination File Path", + "type": "string" + }, + "overwrite": { + "default": false, + "description": "Whether to overwrite the file if it already exists.", + "title": "Overwrite", + "type": "boolean" + }, + "retain_dates": { + "default": false, + "description": "Whether the moved file should retain the original file's created and updated dates.", + "title": "Retain Dates", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from file_system app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "source_file_path", + "destination_file_path", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "file_system__copy_directory", + "description": "Copy a directory to another location.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "source_directory_path": { + "description": "Path of the source directory. Path can be absolute, starting with '/', or relative to the user's home directory, starting with '~/'.", + "title": "Source Directory Path", + "type": "string" + }, + "destination_directory_path": { + "description": "Path of the destination directory. Path can be absolute, starting with '/', or relative to the user's home directory, starting with '~/'.", + "title": "Destination Directory Path", + "type": "string" + }, + "overwrite": { + "default": false, + "description": "Whether to overwrite the directory if it already exists.", + "title": "Overwrite", + "type": "boolean" + }, + "retain_dates": { + "default": false, + "description": "Whether the files in the copied directory should retain the created and updated dates of the files in the original directory.", + "title": "Retain Dates", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from file_system app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "source_directory_path", + "destination_directory_path", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "file_system__move_directory", + "description": "Move a directory to another location.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "source_directory_path": { + "description": "Path of the source directory. Path can be absolute, starting with '/', or relative to the user's home directory, starting with '~/'.", + "title": "Source Directory Path", + "type": "string" + }, + "destination_directory_path": { + "description": "Path of the destination directory. Path can be absolute, starting with '/', or relative to the user's home directory, starting with '~/'.", + "title": "Destination Directory Path", + "type": "string" + }, + "overwrite": { + "default": false, + "description": "Whether to overwrite the directory if it already exists.", + "title": "Overwrite", + "type": "boolean" + }, + "retain_dates": { + "default": false, + "description": "Whether the files in the moved directory should retain the created and updated dates of the files in the original directory.", + "title": "Retain Dates", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from file_system app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "source_directory_path", + "destination_directory_path", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "file_system__compress_directory", + "description": "Compress a directory.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "directory_path": { + "description": "Path of the directory to compress. Path can be absolute, starting with '/', or relative to the user's home directory, starting with '~/'.", + "title": "Directory Path", + "type": "string" + }, + "compressed_file_path": { + "default": null, + "description": "Path of the compressed file. Path can be absolute, starting with '/', or relative to the user's home directory, starting with '~/'. If passed, must have an extension: zip or tar. If not passed, it'll be stored as a zip file with the base name of directory_path in directory_path's parent directory.", + "title": "Compressed File Path", + "type": "string" + }, + "delete_directory": { + "default": false, + "description": "Whether to delete the directory after compression.", + "title": "Delete Directory", + "type": "boolean" + }, + "overwrite": { + "default": false, + "description": "Whether to overwrite the compressed file if it already exists.", + "title": "Overwrite", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from file_system app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "directory_path", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "file_system__decompress_file", + "description": "Decompress a compressed file.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "compressed_file_path": { + "description": "Path of the compressed file. Path can be absolute, starting with '/', or relative to the user's home directory, starting with '~/'. Must have an extension: zip or tar.", + "title": "Compressed File Path", + "type": "string" + }, + "decompressed_directory_path": { + "default": null, + "description": "Path of the directory to save decompressed files. Path can be absolute, starting with '/', or relative to the user's home directory, starting with '~/'. If not passed, it'll be saved in the same directory as the compressed file.", + "title": "Decompressed Directory Path", + "type": "string" + }, + "overwrite": { + "default": false, + "description": "Whether to overwrite the decompressed directory if it already exists.", + "title": "Overwrite", + "type": "boolean" + }, + "retain_dates": { + "default": true, + "description": "Whether the decompressed files should retain the created and updated dates of the files in the compressed file.", + "title": "Retain Dates", + "type": "boolean" + }, + "delete_compressed_file": { + "default": false, + "description": "Whether to delete the compressed file after decompression.", + "title": "Delete Compressed File", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from file_system app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "compressed_file_path", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_account", + "description": "Show your account information. Unlike show_profile, this includes private information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__signup", + "description": "Sign up to create account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "first_name": { + "description": "Your first name", + "minLength": 1, + "title": "First Name", + "type": "string" + }, + "last_name": { + "description": "Your last name", + "minLength": 1, + "title": "Last Name", + "type": "string" + }, + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + }, + "password": { + "description": "Your password.", + "minLength": 5, + "title": "Password", + "type": "string" + } + }, + "required": [ + "first_name", + "last_name", + "email", + "password" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__delete_account", + "description": "Delete your account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__update_account_name", + "description": "Update your first or last name in the account profile.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "first_name": { + "default": null, + "description": "Your updated first name.", + "minLength": 1, + "title": "First Name", + "type": "string" + }, + "last_name": { + "default": null, + "description": "Your updated last name.", + "minLength": 1, + "title": "Last Name", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__login", + "description": "Login to your account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "username": { + "description": "Your account email.", + "title": "Username", + "type": "string" + }, + "password": { + "description": "Your account password.", + "title": "Password", + "type": "string" + } + }, + "required": [ + "username", + "password" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__logout", + "description": "Logout from your account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__send_verification_code", + "description": "Send account verification code to your email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + } + }, + "required": [ + "email" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__verify_account", + "description": "Verify your account using the verification code sent to your email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + }, + "verification_code": { + "description": "The verification code sent to your email address.", + "title": "Verification Code", + "type": "string" + } + }, + "required": [ + "email", + "verification_code" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__send_password_reset_code", + "description": "Send password reset code to your email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + } + }, + "required": [ + "email" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__reset_password", + "description": "Reset your password using the password reset code sent to your email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + }, + "password_reset_code": { + "description": "The password reset code sent to your email address.", + "title": "Password Reset Code", + "type": "string" + }, + "new_password": { + "description": "Your new password.", + "minLength": 5, + "title": "New Password", + "type": "string" + } + }, + "required": [ + "email", + "password_reset_code", + "new_password" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_profile", + "description": "Show public profile information of a user.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "default": null, + "description": "Email of the person you want to see the profile information of.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + } + }, + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__search_users", + "description": "Search users by name or email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "Search query string.", + "title": "Query", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + } + }, + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_genres", + "description": "Show the list of all music genres.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": {}, + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__search_songs", + "description": "Search for songs with a query.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "The search query string.", + "title": "Query", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "artist_id": { + "default": null, + "description": "The artist id to filter the search results by.", + "title": "Artist Id", + "type": "integer" + }, + "album_id": { + "default": null, + "description": "The album id to filter the search results by.", + "title": "Album Id", + "type": "integer" + }, + "genre": { + "default": null, + "description": "The genre to filter the albums by.", + "title": "Genre", + "type": "string" + }, + "min_release_date": { + "default": "1500-01-01", + "description": "The minimum release date for search results in YYYY-MM-DD format.", + "title": "Min Release Date", + "type": "string" + }, + "max_release_date": { + "default": "3000-01-01", + "description": "The maximum release date for search results in YYYY-MM-DD format.", + "title": "Max Release Date", + "type": "string" + }, + "min_duration": { + "default": 0, + "description": "The minimum duration in seconds for search results.", + "minimum": 0, + "title": "Min Duration", + "type": "integer" + }, + "max_duration": { + "default": 9223372036854775807, + "description": "The maximum duration in seconds for search results.", + "minimum": 0, + "title": "Max Duration", + "type": "integer" + }, + "min_rating": { + "default": 0.0, + "description": "The minimum rating for search results.", + "maximum": 5.0, + "minimum": 0.0, + "title": "Min Rating", + "type": "number" + }, + "max_rating": { + "default": 5.0, + "description": "The maximum rating for search results.", + "maximum": 5.0, + "minimum": 0.0, + "title": "Max Rating", + "type": "number" + }, + "min_like_count": { + "default": 0, + "description": "The minimum like count for search results.", + "minimum": 0, + "title": "Min Like Count", + "type": "integer" + }, + "max_like_count": { + "default": 9223372036854775807, + "description": "The maximum like count for search results.", + "minimum": 0, + "title": "Max Like Count", + "type": "integer" + }, + "min_play_count": { + "default": 0, + "description": "The minimum play count for search results.", + "minimum": 0, + "title": "Min Play Count", + "type": "integer" + }, + "max_play_count": { + "default": 9223372036854775807, + "description": "The maximum play count for search results.", + "minimum": 0, + "title": "Max Play Count", + "type": "integer" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the songs by prefixed with +/- to reflect ascending/descending. Valid attributes: rating, like_count and play_count. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page.", + "title": "Sort By", + "type": "string" + } + }, + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_song", + "description": "Get details of a specific song.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "song_id": { + "description": "The song id to retrieve.", + "title": "Song Id", + "type": "integer" + } + }, + "required": [ + "song_id" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_song_privates", + "description": "Show information about the song that is private to the user.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "song_id": { + "description": "The song id to retrieve.", + "title": "Song Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "song_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__like_song", + "description": "Like a song.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "song_id": { + "description": "The song id to like.", + "title": "Song Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "song_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__unlike_song", + "description": "Unlike a song.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "song_id": { + "description": "The song id to unlike.", + "title": "Song Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "song_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_liked_songs", + "description": "Get a list of songs you have liked.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "sort_by": { + "default": "-liked_at", + "description": "The attribute to sort the liked songs by prefixed with +/- to reflect ascending/descending. Valid attributes: liked_at, play_count and title.", + "title": "Sort By", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__search_albums", + "description": "Search for albums with a query.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "The search query string.", + "title": "Query", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "min_rating": { + "default": 0.0, + "description": "The minimum rating for search results.", + "maximum": 5.0, + "minimum": 0.0, + "title": "Min Rating", + "type": "number" + }, + "max_rating": { + "default": 5.0, + "description": "The maximum rating for search results.", + "maximum": 5.0, + "minimum": 0.0, + "title": "Max Rating", + "type": "number" + }, + "min_release_date": { + "default": "1500-01-01", + "description": "The minimum release date for search results in YYYY-MM-DD format.", + "title": "Min Release Date", + "type": "string" + }, + "max_release_date": { + "default": "3000-01-01", + "description": "The maximum release date for search results in YYYY-MM-DD format.", + "title": "Max Release Date", + "type": "string" + }, + "min_like_count": { + "default": 0, + "description": "The minimum like count for search results.", + "minimum": 0, + "title": "Min Like Count", + "type": "integer" + }, + "max_like_count": { + "default": 9223372036854775807, + "description": "The maximum like count for search results.", + "minimum": 0, + "title": "Max Like Count", + "type": "integer" + }, + "genre": { + "default": null, + "description": "The genre to filter the albums by.", + "title": "Genre", + "type": "string" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the albums by prefixed with +/- to reflect ascending/descending. Valid attributes: rating and release_date. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page.", + "title": "Sort By", + "type": "string" + } + }, + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_album", + "description": "Get details of a specific album.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "album_id": { + "description": "The album id to retrieve.", + "title": "Album Id", + "type": "integer" + } + }, + "required": [ + "album_id" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_album_privates", + "description": "Show information about the album that is private to the user.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "album_id": { + "description": "The album id to retrieve.", + "title": "Album Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "album_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__like_album", + "description": "Like a album.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "album_id": { + "description": "The album id to like.", + "title": "Album Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "album_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__unlike_album", + "description": "Unlike a album.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "album_id": { + "description": "The album id to unlike.", + "title": "Album Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "album_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_liked_albums", + "description": "Get a list of albums you have liked.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "sort_by": { + "default": "-liked_at", + "description": "The attribute to sort the liked albums by prefixed with +/- to reflect ascending/descending. Valid attributes: liked_at and title.", + "title": "Sort By", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_playlist_library", + "description": "Search or show a list of playlists in your playlist library.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "The search query string.", + "title": "Query", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "is_public": { + "default": null, + "description": "Whether to show public playlists or private playlists.", + "title": "Is Public", + "type": "boolean" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the library playlists by prefixed with +/- to reflect ascending/descending. Valid attributes: created_at and title. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page. If both query and sort_by are not given, null, or empty, sort_by will default to -created_at.", + "title": "Sort By", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__search_playlists", + "description": "Search for playlists with a query. It will search over all public playlists and your own private playlists. If the access token is not provided, it will only search public playlists.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "The search query string.", + "title": "Query", + "type": "string" + }, + "min_like_count": { + "default": 0, + "description": "The minimum like count for search results.", + "minimum": 0, + "title": "Min Like Count", + "type": "integer" + }, + "max_like_count": { + "default": 9223372036854775807, + "description": "The maximum like count for search results.", + "minimum": 0, + "title": "Max Like Count", + "type": "integer" + }, + "min_rating": { + "default": 0.0, + "description": "The minimum rating for search results.", + "maximum": 5.0, + "minimum": 0.0, + "title": "Min Rating", + "type": "number" + }, + "max_rating": { + "default": 5.0, + "description": "The maximum rating for search results.", + "maximum": 5.0, + "minimum": 0.0, + "title": "Max Rating", + "type": "number" + }, + "owner_email": { + "default": null, + "description": "If passed, will filter results to only the ones owned by this user.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Owner Email", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the playlists by prefixed with +/- to reflect ascending/descending. Valid attributes: like_count, rating and created_at. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page. If both query and sort_by are not given, null, or empty, sort_by will default to -like_count.", + "title": "Sort By", + "type": "string" + }, + "access_token": { + "default": null, + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__create_playlist", + "description": "Create a new playlist.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "title": { + "description": "The title of the playlist.", + "minLength": 1, + "title": "Title", + "type": "string" + }, + "is_public": { + "default": false, + "description": "Whether the playlist is public or not.", + "title": "Is Public", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "title", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_playlist", + "description": "Get detailed information about a specific playlist. You can view your own playlists or others' playlists if they are public.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "playlist_id": { + "description": "The playlist id to retrieve.", + "title": "Playlist Id", + "type": "integer" + }, + "access_token": { + "default": null, + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "playlist_id" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__update_playlist", + "description": "Update a playlist title or privacy.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "title": { + "default": null, + "description": "The updated title of the playlist.", + "minLength": 1, + "title": "Title", + "type": "string" + }, + "is_public": { + "default": null, + "description": "Whether the playlist is public or not.", + "title": "Is Public", + "type": "boolean" + }, + "playlist_id": { + "description": "The playlist id to update.", + "title": "Playlist Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "playlist_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__delete_playlist", + "description": "Delete a playlist.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "playlist_id": { + "description": "The playlist id to delete.", + "title": "Playlist Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "playlist_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_playlist_privates", + "description": "Show information about the playlist that is private to the user.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "playlist_id": { + "description": "The playlist id to retrieve.", + "title": "Playlist Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "playlist_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__like_playlist", + "description": "Like a playlist.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "playlist_id": { + "description": "The playlist id to like.", + "title": "Playlist Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "playlist_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__unlike_playlist", + "description": "Unlike a playlist.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "playlist_id": { + "description": "The playlist id to unlike.", + "title": "Playlist Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "playlist_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_liked_playlists", + "description": "Get a list of playlists you have liked.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "sort_by": { + "default": "-liked_at", + "description": "The attribute to sort the liked playlists by prefixed with +/- to reflect ascending/descending. Valid attributes: liked_at and title.", + "title": "Sort By", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__search_artists", + "description": "Search for artists with a query.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "The search query string.", + "title": "Query", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "genre": { + "default": null, + "description": "The genre to filter the artists by.", + "title": "Genre", + "type": "string" + }, + "min_follower_count": { + "default": 0, + "description": "The minimum number of followers for search results.", + "minimum": 0, + "title": "Min Follower Count", + "type": "integer" + }, + "max_follower_count": { + "default": 9223372036854775807, + "description": "The maximum number of followers for search results.", + "minimum": 0, + "title": "Max Follower Count", + "type": "integer" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the artists by prefixed with +/- to reflect ascending/descending. Valid attributes: follower_count. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page.", + "title": "Sort By", + "type": "string" + } + }, + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_artist", + "description": "Get details of a specific artist.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "artist_id": { + "description": "The artist id to retrieve.", + "title": "Artist Id", + "type": "integer" + } + }, + "required": [ + "artist_id" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_artist_following", + "description": "Show if the user is following the artist.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "artist_id": { + "description": "The artist id to retrieve.", + "title": "Artist Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "artist_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_song_library", + "description": "Search or show a list of songs in your song library.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "The search query string.", + "title": "Query", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the library songs by prefixed with +/- to reflect ascending/descending. Valid attributes: added_at and title. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page. If both query and sort_by are not given, null, or empty, sort_by will default to -added_at.", + "title": "Sort By", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__add_song_to_library", + "description": "Add a song to your song library.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "song_id": { + "description": "The song id to add.", + "title": "Song Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "song_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__remove_song_from_library", + "description": "Remove a song from your song library.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "song_id": { + "description": "The song id to remove.", + "title": "Song Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "song_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_album_library", + "description": "Search or show a list of albums in your album library.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "The search query string.", + "title": "Query", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the library albums by prefixed with +/- to reflect ascending/descending. Valid attributes: added_at and title. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page. If both query and sort_by are not given, null, or empty, sort_by will default to -added_at.", + "title": "Sort By", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__add_album_to_library", + "description": "Add an album to your album library.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "album_id": { + "description": "The album id to add.", + "title": "Album Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "album_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__remove_album_from_library", + "description": "Remove an album from your album library.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "album_id": { + "description": "The album id to remove.", + "title": "Album Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "album_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__add_song_to_playlist", + "description": "Add a song to a playlist.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "playlist_id": { + "description": "The playlist id to add the song to.", + "title": "Playlist Id", + "type": "integer" + }, + "song_id": { + "description": "The song id to add.", + "title": "Song Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "playlist_id", + "song_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__remove_song_from_playlist", + "description": "Remove a song from a playlist.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "playlist_id": { + "description": "The playlist id to remove the song from.", + "title": "Playlist Id", + "type": "integer" + }, + "song_id": { + "description": "The song id to remove.", + "title": "Song Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "playlist_id", + "song_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_downloaded_songs", + "description": "Search or show a list of your downloaded songs.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "The search query string.", + "title": "Query", + "type": "string" + }, + "min_downloaded_at": { + "default": "1500-01-01", + "description": "Filter songs by minimum downloaded_at date in YYYY-MM-DD format.", + "title": "Min Downloaded At", + "type": "string" + }, + "max_downloaded_at": { + "default": "3000-01-01", + "description": "Filter songs by maximum downloaded_at date in YYYY-MM-DD format.", + "title": "Max Downloaded At", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the downloaded songs by prefixed with +/- to reflect ascending/descending. Valid attributes: created_at and downloaded_at. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page. If both query and sort_by are not given, null, or empty, sort_by will default to -downloaded_at.", + "title": "Sort By", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__download_song", + "description": "Download a song.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "song_id": { + "description": "The song id to download.", + "title": "Song Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "song_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__remove_downloaded_song", + "description": "Remove a song from downloads.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "song_id": { + "description": "The song id to remove from downloads.", + "title": "Song Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "song_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_following_artists", + "description": "Search or show a list of artists you are following.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "The search query string.", + "title": "Query", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the following artists by prefixed with +/- to reflect ascending/descending. Valid attributes: followed_at and name. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page. If both query and sort_by are not given, null, or empty, sort_by will default to -followed_at.", + "title": "Sort By", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__follow_artist", + "description": "Follow an artist.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "artist_id": { + "description": "The artist id to follow.", + "title": "Artist Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "artist_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__unfollow_artist", + "description": "Unfollow an artist.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "artist_id": { + "description": "The artist id to unfollow.", + "title": "Artist Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "artist_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_song_reviews", + "description": "Search or show a list of reviews for a song.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "song_id": { + "description": "The song id to retrieve reviews for.", + "title": "Song Id", + "type": "integer" + }, + "query": { + "default": "", + "description": "The search query string.", + "title": "Query", + "type": "string" + }, + "user_email": { + "default": null, + "description": "Email of the user to filter reviews by.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "User Email", + "type": "string" + }, + "min_rating": { + "default": 1, + "description": "The minimum rating for a review.", + "maximum": 5, + "minimum": 1, + "title": "Min Rating", + "type": "integer" + }, + "max_rating": { + "default": 5, + "description": "The maximum rating for a review.", + "maximum": 5, + "minimum": 1, + "title": "Max Rating", + "type": "integer" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the song reviews by prefixed with +/- to reflect ascending/descending. Valid attributes: rating and created_at. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page.", + "title": "Sort By", + "type": "string" + } + }, + "required": [ + "song_id" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__review_song", + "description": "Rate or review a song.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "rating": { + "description": "Song rating for this review.", + "maximum": 5, + "minimum": 1, + "title": "Rating", + "type": "integer" + }, + "title": { + "default": "", + "description": "Title of the review.", + "title": "Title", + "type": "string" + }, + "text": { + "default": "", + "description": "Text content of the review.", + "title": "Text", + "type": "string" + }, + "song_id": { + "description": "ID of the song to review.", + "title": "Song Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "rating", + "song_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__update_song_review", + "description": "Update a song review.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "rating": { + "default": null, + "description": "Song rating for this review.", + "maximum": 5, + "minimum": 1, + "title": "Rating", + "type": "integer" + }, + "title": { + "default": null, + "description": "Title of the review.", + "title": "Title", + "type": "string" + }, + "text": { + "default": null, + "description": "Text content of the review.", + "title": "Text", + "type": "string" + }, + "review_id": { + "description": "ID of the song review.", + "title": "Review Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "review_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__delete_song_review", + "description": "Delete a song review.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "review_id": { + "description": "ID of the song review.", + "title": "Review Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "review_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_song_review", + "description": "Show a song review.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "review_id": { + "description": "The song review id to retrieve.", + "title": "Review Id", + "type": "integer" + } + }, + "required": [ + "review_id" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_album_reviews", + "description": "Search or show a list of reviews for an album.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "album_id": { + "description": "The album id to retrieve reviews for.", + "title": "Album Id", + "type": "integer" + }, + "query": { + "default": "", + "description": "The search query string.", + "title": "Query", + "type": "string" + }, + "user_email": { + "default": null, + "description": "Email of the user to filter reviews by.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "User Email", + "type": "string" + }, + "min_rating": { + "default": 1, + "description": "The minimum rating for a review.", + "maximum": 5, + "minimum": 1, + "title": "Min Rating", + "type": "integer" + }, + "max_rating": { + "default": 5, + "description": "The maximum rating for a review.", + "maximum": 5, + "minimum": 1, + "title": "Max Rating", + "type": "integer" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the album reviews by prefixed with +/- to reflect ascending/descending. Valid attributes: rating and created_at. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page.", + "title": "Sort By", + "type": "string" + } + }, + "required": [ + "album_id" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__review_album", + "description": "Rate or review an album.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "rating": { + "description": "Album rating for this review.", + "maximum": 5, + "minimum": 1, + "title": "Rating", + "type": "integer" + }, + "title": { + "default": "", + "description": "Title of the review.", + "title": "Title", + "type": "string" + }, + "text": { + "default": "", + "description": "Text content of the review.", + "title": "Text", + "type": "string" + }, + "album_id": { + "description": "ID of the album to review.", + "title": "Album Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "rating", + "album_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__update_album_review", + "description": "Update an album review.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "rating": { + "default": null, + "description": "Album rating for this review.", + "maximum": 5, + "minimum": 1, + "title": "Rating", + "type": "integer" + }, + "title": { + "default": null, + "description": "Title of the review.", + "title": "Title", + "type": "string" + }, + "text": { + "default": null, + "description": "Text content of the review.", + "title": "Text", + "type": "string" + }, + "review_id": { + "description": "ID of the album review.", + "title": "Review Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "review_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__delete_album_review", + "description": "Delete an album review.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "review_id": { + "description": "ID of the album review.", + "title": "Review Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "review_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_album_review", + "description": "Show an album review.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "review_id": { + "description": "The album review id to retrieve.", + "title": "Review Id", + "type": "integer" + } + }, + "required": [ + "review_id" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_playlist_reviews", + "description": "Search or show a list of reviews for your playlist or others' public playlist.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "playlist_id": { + "description": "The playlist id to retrieve reviews for.", + "title": "Playlist Id", + "type": "integer" + }, + "query": { + "default": "", + "description": "The search query string.", + "title": "Query", + "type": "string" + }, + "user_email": { + "default": null, + "description": "Email of the user to filter reviews by.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "User Email", + "type": "string" + }, + "min_rating": { + "default": 1, + "description": "The minimum rating for a review.", + "maximum": 5, + "minimum": 1, + "title": "Min Rating", + "type": "integer" + }, + "max_rating": { + "default": 5, + "description": "The maximum rating for a review.", + "maximum": 5, + "minimum": 1, + "title": "Max Rating", + "type": "integer" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the playlist reviews by prefixed with +/- to reflect ascending/descending. Valid attributes: rating and created_at. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page.", + "title": "Sort By", + "type": "string" + }, + "access_token": { + "default": null, + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "playlist_id" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__review_playlist", + "description": "Rate or review a playlist.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "rating": { + "description": "Playlist rating for this review.", + "maximum": 5, + "minimum": 1, + "title": "Rating", + "type": "integer" + }, + "title": { + "default": "", + "description": "Title of the review.", + "title": "Title", + "type": "string" + }, + "text": { + "default": "", + "description": "Text content of the review.", + "title": "Text", + "type": "string" + }, + "playlist_id": { + "description": "ID of the playlist to review.", + "title": "Playlist Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "rating", + "playlist_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__update_playlist_review", + "description": "Update a playlist review.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "rating": { + "default": null, + "description": "Playlist rating for this review.", + "maximum": 5, + "minimum": 1, + "title": "Rating", + "type": "integer" + }, + "title": { + "default": null, + "description": "Title of the review.", + "title": "Title", + "type": "string" + }, + "text": { + "default": null, + "description": "Text content of the review.", + "title": "Text", + "type": "string" + }, + "review_id": { + "description": "ID of the playlist review.", + "title": "Review Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "review_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__delete_playlist_review", + "description": "Delete a playlist review.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "review_id": { + "description": "ID of the playlist review.", + "title": "Review Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "review_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_playlist_review", + "description": "Show a playlist review.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "review_id": { + "description": "The song review id to retrieve.", + "title": "Review Id", + "type": "integer" + }, + "access_token": { + "default": null, + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "review_id" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_payment_cards", + "description": "Get a list of users payment cards.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__add_payment_card", + "description": "Add a new payment card.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "card_name": { + "description": "Name of the payment card.", + "minLength": 1, + "title": "Card Name", + "type": "string" + }, + "owner_name": { + "description": "Full name of the owner of the payment card.", + "minLength": 1, + "title": "Owner Name", + "type": "string" + }, + "card_number": { + "description": "16-digit card number.", + "exclusiveMaximum": 10000000000000000, + "minimum": 1000000000000000, + "title": "Card Number", + "type": "integer" + }, + "expiry_year": { + "description": "Expiration year of the payment card.", + "title": "Expiry Year", + "type": "integer" + }, + "expiry_month": { + "description": "Expiration month of the payment card.", + "maximum": 12, + "minimum": 1, + "title": "Expiry Month", + "type": "integer" + }, + "cvv_number": { + "description": "A 3-digit CVV number of the payment card.", + "exclusiveMaximum": 1000, + "minimum": 100, + "title": "Cvv Number", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "card_name", + "owner_name", + "card_number", + "expiry_year", + "expiry_month", + "cvv_number", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_payment_card", + "description": "Get details of a payment card.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "payment_card_id": { + "description": "ID of the payment card to be shown.", + "title": "Payment Card Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "payment_card_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__update_payment_card", + "description": "Update payment card information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "card_name": { + "description": "Name of the payment card to update.", + "minLength": 1, + "title": "Card Name", + "type": "string" + }, + "payment_card_id": { + "description": "ID of the payment card to update.", + "title": "Payment Card Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "card_name", + "payment_card_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__delete_payment_card", + "description": "Delete payment card information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "payment_card_id": { + "description": "ID of the payment card to be deleted.", + "title": "Payment Card Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "payment_card_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_current_song", + "description": "Show details of the current song on the queue.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__play_music", + "description": "Play music based on various criteria. You can pass, at most, any one of queue_position, song_id, album_id or playlist_id. If one of song_id, album_id or playlist_id is passed, that song, album or playlist will be added to the queue and played. Otherwise, the queue will remain unchanged. If queue_position is passed, the song at that position in the queue will be played. If none is passed, the current song in the queue will be played.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "song_id": { + "default": null, + "description": "ID of the song to play.", + "title": "Song Id", + "type": "integer" + }, + "album_id": { + "default": null, + "description": "ID of the album to play.", + "title": "Album Id", + "type": "integer" + }, + "playlist_id": { + "default": null, + "description": "ID of the playlist to play.", + "title": "Playlist Id", + "type": "integer" + }, + "queue_position": { + "default": null, + "description": "Position of the song in the queue to play.", + "minimum": 0, + "title": "Queue Position", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__pause_music", + "description": "Pause the currently playing song.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__previous_song", + "description": "Go to the previous song in the song queue.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__next_song", + "description": "Go to the next song in the song queue.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__move_song_in_queue", + "description": "Move a song in the queue to a new position.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "current_position": { + "description": "The current position of the song in the queue.", + "minimum": 0, + "title": "Current Position", + "type": "integer" + }, + "new_position": { + "description": "The new position of the song in the queue.", + "minimum": 0, + "title": "New Position", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "current_position", + "new_position", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__seek_song", + "description": "Seek the current song to the given number of seconds.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "seek_seconds": { + "description": "The number of seconds to seek.", + "minimum": 0, + "title": "Seek Seconds", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "seek_seconds", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__loop_song", + "description": "Set whether to loop the current song.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "loop": { + "description": "Whether to loop the current song.", + "title": "Loop", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "loop", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__shuffle_song_queue", + "description": "Shuffle songs in the music player queue.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_song_queue", + "description": "Get the music player song queue. Songs are played in the order of the queue in a cycle.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__add_to_queue", + "description": "Add a song, album or playlist to the music player song queue.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "song_id": { + "default": null, + "description": "ID of the song to add to queue.", + "title": "Song Id", + "type": "integer" + }, + "album_id": { + "default": null, + "description": "ID of the album to add to queue.", + "title": "Album Id", + "type": "integer" + }, + "playlist_id": { + "default": null, + "description": "ID of the playlist to add to queue.", + "title": "Playlist Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__clear_song_queue", + "description": "Clear the music player song queue.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__remove_song_from_queue", + "description": "Remove a song at the given position from the music player song queue.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "position": { + "description": "The 0-indexed position of the song in the queue.", + "title": "Position", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "position", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_volume", + "description": "Get the volume level of the music player.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__set_volume", + "description": "Set the volume level of the music player.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "volume": { + "description": "Volume level to set.", + "maximum": 10, + "minimum": 0, + "title": "Volume", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "volume", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_recommendations", + "description": "Get personalized song recommendations for the user.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_premium_plans", + "description": "Show information about premium plans available.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": {}, + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__subscribe_premium", + "description": "Subscribe to premium membership.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "payment_card_id": { + "description": "ID of the payment card to use for buy premium subscription.", + "title": "Payment Card Id", + "type": "integer" + }, + "duration": { + "description": "Duration of the premium subscription.", + "enum": [ + "monthly", + "yearly" + ], + "title": "Duration", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "payment_card_id", + "duration", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__show_premium_subscriptions", + "description": "Show your premium subscription history.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "spotify__download_premium_subscription_receipt", + "description": "Download the receipt for a premium subscription.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "premium_subscription_id": { + "description": "ID of the premium subscription to download the receipt for.", + "title": "Premium Subscription Id", + "type": "integer" + }, + "file_system_access_token": { + "description": "Access token obtained from file_system app login.", + "title": "File System Access Token", + "type": "string" + }, + "download_to_file_path": { + "default": null, + "description": "The file path to download the receipt file to in file system app. Path can be absolute, starting with '/', or relative to the user's home directory, starting with '~/'. If not passed, it will be saved in your ~/downloads directory.", + "title": "Download To File Path", + "type": "string" + }, + "overwrite": { + "default": false, + "description": "Whether to overwrite the file if it already exists.", + "title": "Overwrite", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from spotify app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "premium_subscription_id", + "file_system_access_token", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__show_account", + "description": "Show your account information. Unlike show_profile, this includes private information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__signup", + "description": "Sign up to create account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "first_name": { + "description": "Your first name", + "minLength": 1, + "title": "First Name", + "type": "string" + }, + "last_name": { + "description": "Your last name", + "minLength": 1, + "title": "Last Name", + "type": "string" + }, + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + }, + "password": { + "description": "Your password.", + "minLength": 5, + "title": "Password", + "type": "string" + } + }, + "required": [ + "first_name", + "last_name", + "email", + "password" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__delete_account", + "description": "Delete your account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__update_account_name", + "description": "Update your first or last name in the account profile.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "first_name": { + "default": null, + "description": "Your updated first name.", + "minLength": 1, + "title": "First Name", + "type": "string" + }, + "last_name": { + "default": null, + "description": "Your updated last name.", + "minLength": 1, + "title": "Last Name", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__login", + "description": "Login to your account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "username": { + "description": "Your account email.", + "title": "Username", + "type": "string" + }, + "password": { + "description": "Your account password.", + "title": "Password", + "type": "string" + } + }, + "required": [ + "username", + "password" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__logout", + "description": "Logout from your account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__send_verification_code", + "description": "Send account verification code to your email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + } + }, + "required": [ + "email" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__verify_account", + "description": "Verify your account using the verification code sent to your email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + }, + "verification_code": { + "description": "The verification code sent to your email address.", + "title": "Verification Code", + "type": "string" + } + }, + "required": [ + "email", + "verification_code" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__send_password_reset_code", + "description": "Send password reset code to your email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + } + }, + "required": [ + "email" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__reset_password", + "description": "Reset your password using the password reset code sent to your email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + }, + "password_reset_code": { + "description": "The password reset code sent to your email address.", + "title": "Password Reset Code", + "type": "string" + }, + "new_password": { + "description": "Your new password.", + "minLength": 5, + "title": "New Password", + "type": "string" + } + }, + "required": [ + "email", + "password_reset_code", + "new_password" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__show_profile", + "description": "Show public profile information of a user, including your friendship status with them.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "default": null, + "description": "Email of the person you want to see the profile information of.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__search_users", + "description": "Search Venmo users by name or email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "Search query string.", + "title": "Query", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__search_friends", + "description": "Search your or others' friends by name or email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "Search friends by name or email address.", + "title": "Query", + "type": "string" + }, + "user_email": { + "default": null, + "description": "Email address of the user whose friends you want to see. If not passed, your friends will be returned.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "User Email", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__add_friend", + "description": "Add a friend to your friend list.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "user_email": { + "description": "Email address of the friend to add.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "User Email", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "user_email", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__remove_friend", + "description": "Remove a friend from your friend list.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "user_email": { + "description": "Email address of the friend to remove.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "User Email", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "user_email", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__add_to_venmo_balance", + "description": "Add money to your Venmo balance.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "amount": { + "description": "Amount to add to Venmo balance.", + "exclusiveMinimum": 0.0, + "title": "Amount", + "type": "number" + }, + "payment_card_id": { + "description": "ID of the payment card to use for adding balance.", + "title": "Payment Card Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "amount", + "payment_card_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__show_venmo_balance", + "description": "Show your Venmo balance.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__withdraw_from_venmo_balance", + "description": "Withdraw money from your Venmo balance.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "amount": { + "description": "Amount to withdraw from Venmo balance.", + "exclusiveMinimum": 0.0, + "title": "Amount", + "type": "number" + }, + "payment_card_id": { + "description": "ID of the payment card to credit the withdrawn amount to.", + "title": "Payment Card Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "amount", + "payment_card_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__show_bank_transfer_history", + "description": "Show histroy of money transfer from Venmo to payment card and vice versa.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "transfer_type": { + "default": null, + "description": "Filter bank transfers by type. Will skip filtering if not passed.", + "enum": [ + "card_to_venmo", + "venmo_to_card" + ], + "title": "Transfer Type", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__download_bank_transfer_receipt", + "description": "Download the receipt of money transfer from Venmo to payment card or vice versa.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "bank_transfer_id": { + "description": "ID of the bank transfer to download the receipt for.", + "title": "Bank Transfer Id", + "type": "integer" + }, + "file_system_access_token": { + "description": "Access token obtained from file_system app login.", + "title": "File System Access Token", + "type": "string" + }, + "download_to_file_path": { + "default": null, + "description": "The file path to download the receipt file to in file system app. Path can be absolute, starting with '/', or relative to the user's home directory, starting with '~/'. If not passed, it will be saved in your ~/downloads directory.", + "title": "Download To File Path", + "type": "string" + }, + "overwrite": { + "default": false, + "description": "Whether to overwrite the file if it already exists.", + "title": "Overwrite", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "bank_transfer_id", + "file_system_access_token", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__show_transaction", + "description": "Show transaction details.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "transaction_id": { + "description": "ID of the transaction to retrieve.", + "title": "Transaction Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "transaction_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__update_transaction", + "description": "Update transaction information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "description": { + "default": null, + "description": "Updated description of or note about the transaction.", + "title": "Description", + "type": "string" + }, + "private": { + "default": null, + "description": "Updated privacy of the transaction.", + "title": "Private", + "type": "boolean" + }, + "transaction_id": { + "description": "ID of the transaction to update.", + "title": "Transaction Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "transaction_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__create_transaction", + "description": "Send money to a user.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "receiver_email": { + "description": "Email address of the receiver.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Receiver Email", + "type": "string" + }, + "amount": { + "description": "Amount of the transaction.", + "exclusiveMinimum": 0.0, + "title": "Amount", + "type": "number" + }, + "description": { + "default": "", + "description": "Description of or note about the transaction.", + "title": "Description", + "type": "string" + }, + "payment_card_id": { + "default": null, + "description": "ID of the payment card to use for the transaction. If not passed, Venmo balance will be used.", + "title": "Payment Card Id", + "type": "integer" + }, + "private": { + "default": false, + "description": "Whether the transaction is private or not.", + "title": "Private", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "receiver_email", + "amount", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__show_transactions", + "description": "Search or show a list of your transactions.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "Search query string.", + "title": "Query", + "type": "string" + }, + "user_email": { + "default": null, + "description": "If passed, only transactions between you and user with this email address will be shown.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "User Email", + "type": "string" + }, + "min_created_at": { + "default": "1500-01-01", + "description": "Minimum created_at datetime to filter transactions in YYYY-MM-DD format.", + "title": "Min Created At", + "type": "string" + }, + "max_created_at": { + "default": "3000-01-01", + "description": "Maximum created_at datetime to filter transactions in YYYY-MM-DD format.", + "title": "Max Created At", + "type": "string" + }, + "min_like_count": { + "default": 0, + "description": "Minimum like count to filter transactions.", + "title": "Min Like Count", + "type": "integer" + }, + "max_like_count": { + "default": 9223372036854775807, + "description": "Maximum like count to filter transactions.", + "title": "Max Like Count", + "type": "integer" + }, + "min_amount": { + "default": 0.01, + "description": "Minimum amount to filter transactions.", + "exclusiveMinimum": 0.0, + "title": "Min Amount", + "type": "number" + }, + "max_amount": { + "default": 9.223372036854776e+18, + "description": "Maximum amount to filter transactions.", + "exclusiveMinimum": 0.0, + "title": "Max Amount", + "type": "number" + }, + "private": { + "default": null, + "description": "Filter transactions by privacy.", + "title": "Private", + "type": "boolean" + }, + "direction": { + "default": null, + "description": "Filter transactions by direction of the transaction (sent or received). Will skip filtering if not passed.", + "enum": [ + "sent", + "received" + ], + "title": "Direction", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the transactions by prefixed with +/- to reflect ascending/descending. Valid attributes: created_at, like_count and amount. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page.", + "title": "Sort By", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__download_transaction_receipt", + "description": "Download the receipt of a transaction (money sent from one user to another).", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "transaction_id": { + "description": "ID of the transaction to download the receipt for.", + "title": "Transaction Id", + "type": "integer" + }, + "file_system_access_token": { + "description": "Access token obtained from file_system app login.", + "title": "File System Access Token", + "type": "string" + }, + "download_to_file_path": { + "default": null, + "description": "The file path to download the receipt file to in file system app. Path can be absolute, starting with '/', or relative to the user's home directory, starting with '~/'. If not passed, it will be saved in your ~/downloads directory.", + "title": "Download To File Path", + "type": "string" + }, + "overwrite": { + "default": false, + "description": "Whether to overwrite the file if it already exists.", + "title": "Overwrite", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "transaction_id", + "file_system_access_token", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__like_transaction", + "description": "Like a transaction.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "transaction_id": { + "description": "ID of the transaction to like.", + "title": "Transaction Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "transaction_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__unlike_transaction", + "description": "Unlike a transaction.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "transaction_id": { + "description": "ID of the transaction to unlike.", + "title": "Transaction Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "transaction_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__show_transaction_comments", + "description": "Get a list of transaction comments.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "transaction_id": { + "description": "ID of the transaction to retrieve comments for.", + "title": "Transaction Id", + "type": "integer" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "transaction_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__create_transaction_comment", + "description": "Create a new transaction comment.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "comment": { + "description": "Comment on the transaction.", + "title": "Comment", + "type": "string" + }, + "transaction_id": { + "description": "ID of the transaction to comment on.", + "title": "Transaction Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "comment", + "transaction_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__show_transaction_comment", + "description": "Show detailed information about a transaction comment.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "comment_id": { + "description": "ID of the transaction comment to show details of.", + "title": "Comment Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "comment_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__update_transaction_comment", + "description": "Update a transaction comment.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "comment": { + "description": "Updated comment on the transaction.", + "title": "Comment", + "type": "string" + }, + "comment_id": { + "description": "ID of the transaction comment to update.", + "title": "Comment Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "comment", + "comment_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__delete_transaction_comment", + "description": "Delete a transaction comment.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "comment_id": { + "description": "ID of the transaction comment to be deleted.", + "title": "Comment Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "comment_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__like_transaction_comment", + "description": "Like a transaction comment.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "comment_id": { + "description": "ID of the transaction comment to like.", + "title": "Comment Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "comment_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__unlike_transaction_comment", + "description": "Unlike a previously liked transaction comment.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "comment_id": { + "description": "ID of the transaction comment to like.", + "title": "Comment Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "comment_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__show_payment_card", + "description": "Get details of a payment card.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "payment_card_id": { + "description": "ID of the payment card to be shown.", + "title": "Payment Card Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "payment_card_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__update_payment_card", + "description": "Update payment card information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "card_name": { + "description": "Name of the payment card to update.", + "minLength": 1, + "title": "Card Name", + "type": "string" + }, + "payment_card_id": { + "description": "ID of the payment card to update.", + "title": "Payment Card Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "card_name", + "payment_card_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__delete_payment_card", + "description": "Delete payment card information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "payment_card_id": { + "description": "ID of the payment card to be deleted.", + "title": "Payment Card Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "payment_card_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__show_payment_cards", + "description": "Get a list of users payment cards.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__add_payment_card", + "description": "Add a new payment card.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "card_name": { + "description": "Name of the payment card.", + "minLength": 1, + "title": "Card Name", + "type": "string" + }, + "owner_name": { + "description": "Full name of the owner of the payment card.", + "minLength": 1, + "title": "Owner Name", + "type": "string" + }, + "card_number": { + "description": "16-digit card number.", + "exclusiveMaximum": 10000000000000000, + "minimum": 1000000000000000, + "title": "Card Number", + "type": "integer" + }, + "expiry_year": { + "description": "Expiration year of the payment card.", + "title": "Expiry Year", + "type": "integer" + }, + "expiry_month": { + "description": "Expiration month of the payment card.", + "maximum": 12, + "minimum": 1, + "title": "Expiry Month", + "type": "integer" + }, + "cvv_number": { + "description": "A 3-digit CVV number of the payment card.", + "exclusiveMaximum": 1000, + "minimum": 100, + "title": "Cvv Number", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "card_name", + "owner_name", + "card_number", + "expiry_year", + "expiry_month", + "cvv_number", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__show_received_payment_requests", + "description": "Search or show a list of payment requests you have received from others.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "Search query string.", + "title": "Query", + "type": "string" + }, + "status": { + "default": null, + "description": "Filter payment requests by status. Will skip filtering if not passed.", + "enum": [ + "pending", + "approved", + "denied" + ], + "title": "Status", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__show_sent_payment_requests", + "description": "Search or show a list of payment requests you have sent to others.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "Search query string.", + "title": "Query", + "type": "string" + }, + "status": { + "default": null, + "description": "Filter payment requests by status. Will skip filtering if not passed.", + "enum": [ + "pending", + "approved", + "denied" + ], + "title": "Status", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__create_payment_request", + "description": "Send a payment request.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "user_email": { + "description": "Email address of the receiver user.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "User Email", + "type": "string" + }, + "amount": { + "description": "Amount of the payment request.", + "exclusiveMinimum": 0.0, + "title": "Amount", + "type": "number" + }, + "description": { + "default": "", + "description": "Description of or note about the payment request.", + "title": "Description", + "type": "string" + }, + "private": { + "default": false, + "description": "The privacy of the transaction on approval of the payment request.", + "title": "Private", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "user_email", + "amount", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__update_payment_request", + "description": "Update payment request information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "amount": { + "default": null, + "description": "Updated amount of the payment request.", + "exclusiveMinimum": 0.0, + "title": "Amount", + "type": "number" + }, + "description": { + "default": null, + "description": "Updated description of or note about the payment request.", + "title": "Description", + "type": "string" + }, + "private": { + "default": null, + "description": "Updated privacy of the transaction on payment request approval.", + "title": "Private", + "type": "boolean" + }, + "payment_request_id": { + "description": "ID of the payment request to update.", + "title": "Payment Request Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "payment_request_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__delete_payment_request", + "description": "Delete a payment request.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "payment_request_id": { + "description": "ID of the payment request to delete.", + "title": "Payment Request Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "payment_request_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__approve_payment_request", + "description": "Approve a payment request.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "payment_card_id": { + "default": null, + "description": "ID of the payment card to use for approving the payment request. If not passed, Venmo balance will be used.", + "title": "Payment Card Id", + "type": "integer" + }, + "payment_request_id": { + "description": "ID of the payment request to approve.", + "title": "Payment Request Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "payment_request_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__deny_payment_request", + "description": "Deny a payment request.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "payment_request_id": { + "description": "ID of the payment request to deny.", + "title": "Payment Request Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "payment_request_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__remind_payment_request", + "description": "Send a reminder to a user via notification about this payment request.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "payment_request_id": { + "description": "ID of the payment request to remind about.", + "title": "Payment Request Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "payment_request_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__show_social_feed", + "description": "Show your social feed (transactions of your friends).", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__show_notifications", + "description": "Get a list of your notifications.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "read": { + "default": null, + "description": "Filter notifications by read status. Will skip filtering if not passed.", + "title": "Read", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__delete_notifications", + "description": "Delete all of your notifications.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__mark_notifications", + "description": "Mark all notifications as read or unread.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "read": { + "description": "Read status of the notification.", + "title": "Read", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "read", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__show_notifications_count", + "description": "Get the count of your notifications.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "read": { + "default": null, + "description": "Count notifications by read status. Will count all if not passed.", + "title": "Read", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__delete_notification", + "description": "Delete a notification.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "notification_id": { + "description": "ID of the notification to delete.", + "title": "Notification Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "notification_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "venmo__mark_notification", + "description": "Mark a notification as read or unread.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "read": { + "description": "Mark the notification as read or unread.", + "title": "Read", + "type": "boolean" + }, + "notification_id": { + "description": "ID of the notification to mark.", + "title": "Notification Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from venmo app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "read", + "notification_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__show_account", + "description": "Show your account information. Unlike show_profile, this includes private information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__signup", + "description": "Sign up to create account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "first_name": { + "description": "Your first name", + "minLength": 1, + "title": "First Name", + "type": "string" + }, + "last_name": { + "description": "Your last name", + "minLength": 1, + "title": "Last Name", + "type": "string" + }, + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + }, + "password": { + "description": "Your password.", + "minLength": 5, + "title": "Password", + "type": "string" + } + }, + "required": [ + "first_name", + "last_name", + "email", + "password" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__delete_account", + "description": "Delete your account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__update_account_name", + "description": "Update your first or last name in the account profile.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "first_name": { + "default": null, + "description": "Your updated first name.", + "minLength": 1, + "title": "First Name", + "type": "string" + }, + "last_name": { + "default": null, + "description": "Your updated last name.", + "minLength": 1, + "title": "Last Name", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__login", + "description": "Login to your account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "username": { + "description": "Your account email.", + "title": "Username", + "type": "string" + }, + "password": { + "description": "Your account password.", + "title": "Password", + "type": "string" + } + }, + "required": [ + "username", + "password" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__logout", + "description": "Logout from your account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__send_password_reset_code", + "description": "Send password reset code to your email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + } + }, + "required": [ + "email" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__reset_password", + "description": "Reset your password using the password reset code sent to your email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + }, + "password_reset_code": { + "description": "The password reset code sent to your email address.", + "title": "Password Reset Code", + "type": "string" + }, + "new_password": { + "description": "Your new password.", + "minLength": 5, + "title": "New Password", + "type": "string" + } + }, + "required": [ + "email", + "password_reset_code", + "new_password" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__show_profile", + "description": "Show public profile information of a user.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "default": null, + "description": "Email of the person you want to see the profile information of.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + } + }, + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__set_status", + "description": "Set your availability status.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "status": { + "description": "Your availability status.", + "enum": [ + "active", + "do_not_disturb", + "away" + ], + "title": "Status", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "status", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__search_users", + "description": "Search Gmail users by name or email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "Search query string.", + "title": "Query", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + } + }, + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__search_labels", + "description": "Search email thread labels by name.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "Search query string.", + "title": "Query", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 9223372036854775807, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__show_inbox_threads", + "description": "Show or search email threads you have received. This will not show detailed information about emails within the thread.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "The search query string.", + "title": "Query", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "label": { + "default": null, + "description": "The label to filter the emails by.", + "title": "Label", + "type": "string" + }, + "starred": { + "default": null, + "description": "Filter emails by starred status.", + "title": "Starred", + "type": "boolean" + }, + "archived": { + "default": false, + "description": "Filter emails by archived status.", + "title": "Archived", + "type": "boolean" + }, + "spam": { + "default": false, + "description": "Filter emails by spam status.", + "title": "Spam", + "type": "boolean" + }, + "snoozed": { + "default": false, + "description": "Filter emails by snoozed status.", + "title": "Snoozed", + "type": "boolean" + }, + "read": { + "default": null, + "description": "Filter emails by read status.", + "title": "Read", + "type": "boolean" + }, + "attachment": { + "default": null, + "description": "Filter emails by whether they have an attachment or not.", + "title": "Attachment", + "type": "boolean" + }, + "from_email": { + "default": null, + "description": "Filter emails by sender email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "From Email", + "type": "string" + }, + "to_email": { + "default": null, + "description": "Filter emails by recipient email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "To Email", + "type": "string" + }, + "min_created_at": { + "default": "1500-01-01", + "description": "Filter emails by minimum created_at date in YYYY-MM-DD format.", + "title": "Min Created At", + "type": "string" + }, + "max_created_at": { + "default": "3000-01-01", + "description": "Filter emails by maximum created_at date in YYYY-MM-DD format.", + "title": "Max Created At", + "type": "string" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the email threads by prefixed with +/- to reflect ascending/descending. Valid attributes: created_at and updated_at. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page. If both query and sort_by are not given, null, or empty, sort_by will default to -updated_at.", + "title": "Sort By", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__show_outbox_threads", + "description": "Show or search email threads you have sent. This will not show detailed information about emails within the thread.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "The search query string.", + "title": "Query", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "label": { + "default": null, + "description": "The label to filter the emails by.", + "title": "Label", + "type": "string" + }, + "starred": { + "default": null, + "description": "Filter emails by starred status.", + "title": "Starred", + "type": "boolean" + }, + "archived": { + "default": false, + "description": "Filter emails by archived status.", + "title": "Archived", + "type": "boolean" + }, + "spam": { + "default": false, + "description": "Filter emails by spam status.", + "title": "Spam", + "type": "boolean" + }, + "snoozed": { + "default": false, + "description": "Filter emails by snoozed status.", + "title": "Snoozed", + "type": "boolean" + }, + "read": { + "default": null, + "description": "Filter emails by read status.", + "title": "Read", + "type": "boolean" + }, + "attachment": { + "default": null, + "description": "Filter emails by attachment status.", + "title": "Attachment", + "type": "boolean" + }, + "from_email": { + "default": null, + "description": "Filter emails by sender email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "From Email", + "type": "string" + }, + "to_email": { + "default": null, + "description": "Filter emails by recipient email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "To Email", + "type": "string" + }, + "min_created_at": { + "default": "1500-01-01", + "description": "Filter emails by minimum created_at date in YYYY-MM-DD format.", + "title": "Min Created At", + "type": "string" + }, + "max_created_at": { + "default": "3000-01-01", + "description": "Filter emails by maximum created_at date in YYYY-MM-DD format.", + "title": "Max Created At", + "type": "string" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the email threads by prefixed with +/- to reflect ascending/descending. Valid attributes: created_at and updated_at. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page. If both query and sort_by are not given, null, or empty, sort_by will default to -updated_at.", + "title": "Sort By", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__show_snoozed_threads", + "description": "Show or search email threads you have snoozed. This will not show detailed information about emails within the thread.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "The search query string.", + "title": "Query", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "label": { + "default": null, + "description": "The label to filter the emails by.", + "title": "Label", + "type": "string" + }, + "starred": { + "default": null, + "description": "Filter emails by starred status.", + "title": "Starred", + "type": "boolean" + }, + "read": { + "default": null, + "description": "Filter emails by read status.", + "title": "Read", + "type": "boolean" + }, + "attachment": { + "default": null, + "description": "Filter emails by attachment status.", + "title": "Attachment", + "type": "boolean" + }, + "from_email": { + "default": null, + "description": "Filter emails by sender email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "From Email", + "type": "string" + }, + "to_email": { + "default": null, + "description": "Filter emails by recipient email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "To Email", + "type": "string" + }, + "min_created_at": { + "default": "1500-01-01", + "description": "Filter emails by minimum created_at date in YYYY-MM-DD format.", + "title": "Min Created At", + "type": "string" + }, + "max_created_at": { + "default": "3000-01-01", + "description": "Filter emails by maximum created_at date in YYYY-MM-DD format.", + "title": "Max Created At", + "type": "string" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the email threads by prefixed with +/- to reflect ascending/descending. Valid attributes: created_at and updated_at. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page. If both query and sort_by are not given, null, or empty, sort_by will default to -updated_at.", + "title": "Sort By", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__show_starred_threads", + "description": "Show or search email threads you have starred. This will not show detailed information about emails within the thread.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "The search query string.", + "title": "Query", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "label": { + "default": null, + "description": "The label to filter the emails by.", + "title": "Label", + "type": "string" + }, + "archived": { + "default": null, + "description": "Filter emails by archived status.", + "title": "Archived", + "type": "boolean" + }, + "spam": { + "default": null, + "description": "Filter emails by spam status.", + "title": "Spam", + "type": "boolean" + }, + "snoozed": { + "default": null, + "description": "Filter emails by snoozed status.", + "title": "Snoozed", + "type": "boolean" + }, + "read": { + "default": null, + "description": "Filter emails by read status.", + "title": "Read", + "type": "boolean" + }, + "attachment": { + "default": null, + "description": "Filter emails by attachment status.", + "title": "Attachment", + "type": "boolean" + }, + "from_email": { + "default": null, + "description": "Filter emails by sender email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "From Email", + "type": "string" + }, + "to_email": { + "default": null, + "description": "Filter emails by recipient email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "To Email", + "type": "string" + }, + "min_created_at": { + "default": "1500-01-01", + "description": "Filter emails by minimum created_at date in YYYY-MM-DD format.", + "title": "Min Created At", + "type": "string" + }, + "max_created_at": { + "default": "3000-01-01", + "description": "Filter emails by maximum created_at date in YYYY-MM-DD format.", + "title": "Max Created At", + "type": "string" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the email threads by prefixed with +/- to reflect ascending/descending. Valid attributes: created_at and updated_at. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page. If both query and sort_by are not given, null, or empty, sort_by will default to -updated_at.", + "title": "Sort By", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__show_archived_threads", + "description": "Show or search email threads you have archived. This will not show detailed information about emails within the thread.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "The search query string.", + "title": "Query", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "label": { + "default": null, + "description": "The label to filter the emails by.", + "title": "Label", + "type": "string" + }, + "starred": { + "default": null, + "description": "Filter emails by starred status.", + "title": "Starred", + "type": "boolean" + }, + "read": { + "default": null, + "description": "Filter emails by read status.", + "title": "Read", + "type": "boolean" + }, + "attachment": { + "default": null, + "description": "Filter emails by attachment status.", + "title": "Attachment", + "type": "boolean" + }, + "from_email": { + "default": null, + "description": "Filter emails by sender email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "From Email", + "type": "string" + }, + "to_email": { + "default": null, + "description": "Filter emails by recipient email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "To Email", + "type": "string" + }, + "min_created_at": { + "default": "1500-01-01", + "description": "Filter emails by minimum created_at date in YYYY-MM-DD format.", + "title": "Min Created At", + "type": "string" + }, + "max_created_at": { + "default": "3000-01-01", + "description": "Filter emails by maximum created_at date in YYYY-MM-DD format.", + "title": "Max Created At", + "type": "string" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the email threads by prefixed with +/- to reflect ascending/descending. Valid attributes: created_at and updated_at. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page. If both query and sort_by are not given, null, or empty, sort_by will default to -updated_at.", + "title": "Sort By", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__show_spam_threads", + "description": "Show or search email threads that have been marked as spam. This will not show detailed information about emails within the thread.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "The search query string.", + "title": "Query", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "label": { + "default": null, + "description": "The label to filter the emails by.", + "title": "Label", + "type": "string" + }, + "starred": { + "default": null, + "description": "Filter emails by starred status.", + "title": "Starred", + "type": "boolean" + }, + "read": { + "default": null, + "description": "Filter emails by read status.", + "title": "Read", + "type": "boolean" + }, + "attachment": { + "default": null, + "description": "Filter emails by attachment status.", + "title": "Attachment", + "type": "boolean" + }, + "from_email": { + "default": null, + "description": "Filter emails by sender email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "From Email", + "type": "string" + }, + "to_email": { + "default": null, + "description": "Filter emails by recipient email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "To Email", + "type": "string" + }, + "min_created_at": { + "default": "1500-01-01", + "description": "Filter emails by minimum created_at date in YYYY-MM-DD format.", + "title": "Min Created At", + "type": "string" + }, + "max_created_at": { + "default": "3000-01-01", + "description": "Filter emails by maximum created_at date in YYYY-MM-DD format.", + "title": "Max Created At", + "type": "string" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the email threads by prefixed with +/- to reflect ascending/descending. Valid attributes: created_at and updated_at. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page. If both query and sort_by are not given, null, or empty, sort_by will default to -updated_at.", + "title": "Sort By", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__show_category_sizes", + "description": "Show the number of email threads in each category of inbox, outbox, archived, spam, and the number of unscheduled and scheduled email drafts.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "read": { + "default": null, + "description": "Count only read or unread email threads. If None, count both read and unread threads. It is not applicable for drafts.", + "title": "Read", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__show_thread", + "description": "Show detailed information about a given email thread, including emails and drafts within it.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email_thread_id": { + "description": "The ID of the email thread to show.", + "title": "Email Thread Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "email_thread_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__delete_thread", + "description": "Delete an email thread.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email_thread_id": { + "description": "The ID of the email thread to delete.", + "title": "Email Thread Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "email_thread_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__show_email", + "description": "Show detailed information about a given email.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email_id": { + "description": "The ID of the email to show.", + "title": "Email Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "email_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__label_thread", + "description": "Label an email thread.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "label": { + "description": "The label to assign to the email thread.", + "title": "Label", + "type": "string" + }, + "email_thread_id": { + "description": "The ID of the email thread to label.", + "title": "Email Thread Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "label", + "email_thread_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__unlabel_thread", + "description": "Remove label from an email thread.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email_thread_id": { + "description": "The ID of the email thread to unlabel.", + "title": "Email Thread Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "email_thread_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__mark_thread_read", + "description": "Mark an email thread as read.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email_thread_id": { + "description": "The ID of the email thread to mark as read.", + "title": "Email Thread Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "email_thread_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__mark_thread_unread", + "description": "Mark an email thread as unread.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email_thread_id": { + "description": "The ID of the email thread to mark as unread.", + "title": "Email Thread Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "email_thread_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__mark_thread_archived", + "description": "Mark an email thread as archived. This will also remove the spam and snooze status if any.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email_thread_id": { + "description": "The ID of the email thread to archive.", + "title": "Email Thread Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "email_thread_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__mark_thread_unarchived", + "description": "Mark an email thread as unarchived.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email_thread_id": { + "description": "The ID of the email thread to unarchive.", + "title": "Email Thread Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "email_thread_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__mark_thread_spam", + "description": "Mark an email thread as spam. This will also remove the archived and snooze status if any.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email_thread_id": { + "description": "The ID of the email thread to mark as spam.", + "title": "Email Thread Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "email_thread_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__mark_thread_not_spam", + "description": "Mark an email thread as not spam.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email_thread_id": { + "description": "The ID of the email thread to mark as not spam.", + "title": "Email Thread Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "email_thread_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__mark_thread_starred", + "description": "Mark an email thread as starred.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email_thread_id": { + "description": "The ID of the email thread to star.", + "title": "Email Thread Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "email_thread_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__mark_thread_unstarred", + "description": "Mark an email thread as unstarred.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email_thread_id": { + "description": "The ID of the email thread to unstar.", + "title": "Email Thread Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "email_thread_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__delete_email_in_thread", + "description": "Delete an email in a thread.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email_thread_id": { + "description": "The ID of the email thread to delete an email from.", + "title": "Email Thread Id", + "type": "integer" + }, + "email_id": { + "description": "The ID of the email to delete.", + "title": "Email Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "email_thread_id", + "email_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__snooze_thread", + "description": "Snooze an email thread until a given date and time in the future. It will reappear in unread state in your inbox and/or outbox at that time. It will also be removed from archived or spam categories if it was in either of those.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "snooze_until": { + "description": "The date and time to snooze the email thread until. The date and time should be in YYYY-MM-DD|HH:MM:SS format", + "title": "Snooze Until", + "type": "string" + }, + "email_thread_id": { + "description": "The ID of the email thread to snooze.", + "title": "Email Thread Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "snooze_until", + "email_thread_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__unsnooze_thread", + "description": "Unsnooze an email thread.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email_thread_id": { + "description": "The ID of the email thread to unsnooze.", + "title": "Email Thread Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "email_thread_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__send_email", + "description": "Send a new email to one or more recipients.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email_addresses": { + "description": "List of recipient email addresses.", + "items": { + "type": "string" + }, + "title": "Email Addresses", + "type": "array" + }, + "subject": { + "description": "The subject of the email.", + "title": "Subject", + "type": "string" + }, + "body": { + "description": "The body of the email.", + "title": "Body", + "type": "string" + }, + "attachment_file_paths": { + "default": null, + "description": "List of absolute file paths (starting with /) from the file_system app to attach to the email.", + "items": { + "type": "string" + }, + "title": "Attachment File Paths", + "type": "array" + }, + "file_system_access_token": { + "default": null, + "description": "Access token obtained from file_system app login. Only needed for attachments.", + "title": "File System Access Token", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "email_addresses", + "subject", + "body", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__reply_to_email", + "description": "Reply to an existing email in a thread.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email_addresses": { + "default": null, + "description": "List of recipient email addresses. If passed, it'll reply to all recipients in the list, otherwise, to the sender.", + "items": { + "type": "string" + }, + "title": "Email Addresses", + "type": "array" + }, + "attachment_file_paths": { + "default": null, + "description": "List of absolute file paths (starting with /) from the file_system app to attach to the email.", + "items": { + "type": "string" + }, + "title": "Attachment File Paths", + "type": "array" + }, + "body": { + "description": "The body of the reply email.", + "title": "Body", + "type": "string" + }, + "file_system_access_token": { + "default": null, + "description": "Access token obtained from file_system app login. Only needed for attachments.", + "title": "File System Access Token", + "type": "string" + }, + "email_thread_id": { + "description": "The ID of the email thread to reply to.", + "title": "Email Thread Id", + "type": "integer" + }, + "email_id": { + "description": "The ID of the email to reply to.", + "title": "Email Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "body", + "email_thread_id", + "email_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__forward_email_from_thread", + "description": "Forward an email from an email thread to one or more recipients.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email_addresses": { + "description": "List of recipient email addresses.", + "items": { + "type": "string" + }, + "title": "Email Addresses", + "type": "array" + }, + "draft_not_send": { + "default": false, + "description": "If true, the email will be saved as a draft instead of being sent. This way it can be edited before sending.", + "title": "Draft Not Send", + "type": "boolean" + }, + "email_thread_id": { + "description": "The ID of the email thread to forward.", + "title": "Email Thread Id", + "type": "integer" + }, + "email_id": { + "description": "The ID of the email to forward.", + "title": "Email Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "email_addresses", + "email_thread_id", + "email_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__forward_email_thread", + "description": "Forward entire email thread to one or more recipients.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email_addresses": { + "description": "List of recipient email addresses.", + "items": { + "type": "string" + }, + "title": "Email Addresses", + "type": "array" + }, + "draft_not_send": { + "default": false, + "description": "If true, the email will be saved as a draft instead of being sent. This way it can be edited before sending.", + "title": "Draft Not Send", + "type": "boolean" + }, + "email_thread_id": { + "description": "The ID of the email thread to forward.", + "title": "Email Thread Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "email_addresses", + "email_thread_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__create_draft", + "description": "Create a new draft.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "recipient_email_addresses": { + "description": "List of recipient email addresses.", + "items": { + "type": "string" + }, + "title": "Recipient Email Addresses", + "type": "array" + }, + "subject": { + "default": null, + "description": "The subject of the draft. Must be None if it's a reply to an email.", + "title": "Subject", + "type": "string" + }, + "body": { + "description": "The body of the draft.", + "title": "Body", + "type": "string" + }, + "belongs_to_email_thread_id": { + "default": null, + "description": "The ID of the email thread that the draft should belong to. Must be passed if it's a reply to or forward of an email.", + "title": "Belongs To Email Thread Id", + "type": "integer" + }, + "response_to_email_id": { + "default": null, + "description": "The ID of the email in the thread that the draft should responds to.", + "title": "Response To Email Id", + "type": "integer" + }, + "attachment_file_paths": { + "default": null, + "description": "List of absolute file paths (starting with /) from the file_system app to attach to the draft.", + "items": { + "type": "string" + }, + "title": "Attachment File Paths", + "type": "array" + }, + "scheduled_send_at": { + "default": null, + "description": "If set, the draft will be sent at the specified future time in YYYY-MM-DD|HH:MM:SS format. Otherwise, it will not be sent until you manually send it.", + "title": "Scheduled Send At", + "type": "string" + }, + "file_system_access_token": { + "default": null, + "description": "Access token obtained from file_system app login. Only needed for attachments.", + "title": "File System Access Token", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "recipient_email_addresses", + "body", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__show_drafts", + "description": "Search or show a list of your drafts.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "The search query string.", + "title": "Query", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "recipient_email": { + "default": null, + "description": "Filter emails by recipient email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Recipient Email", + "type": "string" + }, + "attachment": { + "default": null, + "description": "Filter drafts by attachment status.", + "title": "Attachment", + "type": "boolean" + }, + "scheduled": { + "default": null, + "description": "Filter drafts by whether they are scheduled to be sent in the future.", + "title": "Scheduled", + "type": "boolean" + }, + "belongs_to_email_thread_id": { + "default": null, + "description": "Filter drafts by email thread ID that the draft belongs to.", + "title": "Belongs To Email Thread Id", + "type": "integer" + }, + "response_to_email_id": { + "default": null, + "description": "Filter drafts by email ID that the draft responds to.", + "title": "Response To Email Id", + "type": "integer" + }, + "min_created_at": { + "default": "1500-01-01", + "description": "Filter drafts by minimum created_at date in YYYY-MM-DD format.", + "title": "Min Created At", + "type": "string" + }, + "max_created_at": { + "default": "3000-01-01", + "description": "Filter drafts by maximum created_at date in YYYY-MM-DD format.", + "title": "Max Created At", + "type": "string" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the drafts by prefixed with +/- to reflect ascending/descending. Valid attributes: created_at and updated_at. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page. If both query and sort_by are not given, null, or empty, sort_by will default to -updated_at.", + "title": "Sort By", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__update_draft", + "description": "Update draft information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email_addresses": { + "default": null, + "description": "List of recipient email addresses.", + "items": { + "type": "string" + }, + "title": "Email Addresses", + "type": "array" + }, + "subject": { + "default": null, + "description": "The updated subject of the draft.", + "title": "Subject", + "type": "string" + }, + "body": { + "default": null, + "description": "The updated body of the draft.", + "title": "Body", + "type": "string" + }, + "belongs_to_email_thread_id": { + "default": null, + "description": "The updated ID of the email thread that the draft should belong to. Must be passed if it's a reply to an email.", + "title": "Belongs To Email Thread Id", + "type": "integer" + }, + "response_to_email_id": { + "default": null, + "description": "The updated ID of the email in the thread that the draft is a response to.", + "title": "Response To Email Id", + "type": "integer" + }, + "scheduled_send_at": { + "default": null, + "description": "The updated time at which the draft should be sent in YYYY-MM-DD|HH:MM:SS format.To remove the scheduled delivery, pass 'None' string, as leaving it empty or null is for when you don't want to update it.", + "title": "Scheduled Send At", + "type": "string" + }, + "draft_id": { + "description": "The ID of the draft to update.", + "title": "Draft Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "draft_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__delete_draft", + "description": "Delete draft information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "draft_id": { + "description": "The ID of the draft to delete.", + "title": "Draft Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "draft_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__show_draft", + "description": "Show detailed draft information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "draft_id": { + "description": "The ID of the draft to show.", + "title": "Draft Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "draft_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__send_email_from_draft", + "description": "Send a new email from a draft right away.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "file_system_access_token": { + "default": null, + "description": "Access token obtained from file_system app login. Only needed if draft has attachments.", + "title": "File System Access Token", + "type": "string" + }, + "draft_id": { + "description": "The ID of the draft to send.", + "title": "Draft Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "draft_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__download_attachment", + "description": "Download a file attachment.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "attachment_id": { + "description": "The ID of the attachment to download.", + "title": "Attachment Id", + "type": "integer" + }, + "download_to_file_path": { + "default": null, + "description": "The file path to download the attachment to in file system app. Path can be absolute, starting with '/', or relative to the user's home directory, starting with '~/'. If not passed, it will be saved in your ~/downloads directory.", + "title": "Download To File Path", + "type": "string" + }, + "overwrite": { + "default": false, + "description": "Whether to overwrite the file if it already exists.", + "title": "Overwrite", + "type": "boolean" + }, + "file_system_access_token": { + "description": "Access token obtained from file_system app login.", + "title": "File System Access Token", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "attachment_id", + "file_system_access_token", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__upload_attachments_to_draft", + "description": "Upload attachments to a draft.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "attachment_file_paths": { + "description": "List of absolute file paths (starting with /) from the file_system app to attach to the email.", + "items": { + "type": "string" + }, + "title": "Attachment File Paths", + "type": "array" + }, + "overwrite": { + "default": false, + "description": "Whether to overwrite the attachment if it already exists.", + "title": "Overwrite", + "type": "boolean" + }, + "file_system_access_token": { + "description": "Access token obtained from file_system app login.", + "title": "File System Access Token", + "type": "string" + }, + "draft_id": { + "description": "The ID of the draft to upload the attachment to.", + "title": "Draft Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "attachment_file_paths", + "file_system_access_token", + "draft_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "gmail__remove_attachment_from_draft", + "description": "Delete an attachment from a draft.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "draft_id": { + "description": "The ID of the draft to delete the attachment from.", + "title": "Draft Id", + "type": "integer" + }, + "attachment_id": { + "description": "The ID of the attachment to delete.", + "title": "Attachment Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from gmail app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "draft_id", + "attachment_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__show_account", + "description": "Show your account information. Unlike show_profile, this includes private information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__signup", + "description": "Sign up to create account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "first_name": { + "description": "Your first name.", + "minLength": 1, + "title": "First Name", + "type": "string" + }, + "last_name": { + "description": "Your last name.", + "minLength": 1, + "title": "Last Name", + "type": "string" + }, + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + }, + "password": { + "description": "Your password.", + "minLength": 5, + "title": "Password", + "type": "string" + } + }, + "required": [ + "first_name", + "last_name", + "email", + "password" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__delete_account", + "description": "Delete your account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__update_account_name", + "description": "Update your first or last name in the account profile.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "first_name": { + "default": null, + "description": "Your updated first name.", + "minLength": 1, + "title": "First Name", + "type": "string" + }, + "last_name": { + "default": null, + "description": "Your updated last name.", + "minLength": 1, + "title": "Last Name", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__login", + "description": "Login to your account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "username": { + "description": "Your account email.", + "title": "Username", + "type": "string" + }, + "password": { + "description": "Your account password.", + "title": "Password", + "type": "string" + } + }, + "required": [ + "username", + "password" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__logout", + "description": "Logout from your account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__send_verification_code", + "description": "Send account verification code to your email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + } + }, + "required": [ + "email" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__verify_account", + "description": "Verify your account using the verification code sent to your email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + }, + "verification_code": { + "description": "The verification code sent to your email address.", + "title": "Verification Code", + "type": "string" + } + }, + "required": [ + "email", + "verification_code" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__send_password_reset_code", + "description": "Send password reset code to your email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + } + }, + "required": [ + "email" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__reset_password", + "description": "Reset your password using the password reset code sent to your email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + }, + "password_reset_code": { + "description": "The password reset code sent to your email address.", + "title": "Password Reset Code", + "type": "string" + }, + "new_password": { + "description": "Your new password.", + "minLength": 5, + "title": "New Password", + "type": "string" + } + }, + "required": [ + "email", + "password_reset_code", + "new_password" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__show_profile", + "description": "Show public profile information of a user.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "default": null, + "description": "Email of the person you want to see the profile information of.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + } + }, + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__search_users", + "description": "Search Splitwise users by name or email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "Search query string.", + "title": "Query", + "type": "string" + }, + "include_self": { + "default": false, + "description": "Whether to include the current user in the search results.", + "title": "Include Self", + "type": "boolean" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__show_activity", + "description": "Show a history of your expenses and payments combined.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "show_expenses": { + "default": true, + "description": "Whether to show expenses in the history.", + "title": "Show Expenses", + "type": "boolean" + }, + "show_payments": { + "default": true, + "description": "Whether to show payments in the history.", + "title": "Show Payments", + "type": "boolean" + }, + "sort_by": { + "default": "-created_at", + "description": "The attribute to sort the expenses/payments by prefixed with +/- to reflect ascending/descending. Valid attributes: created_at.", + "title": "Sort By", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__create_group", + "description": "Create a new group of friends or family to share expenses with.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "name": { + "description": "The name of the group.", + "minLength": 1, + "title": "Name", + "type": "string" + }, + "member_emails": { + "description": "Emails of the users to add to the group besides yourself.", + "items": { + "type": "string" + }, + "title": "Member Emails", + "type": "array" + }, + "description": { + "default": null, + "description": "The description of the group.", + "title": "Description", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "name", + "member_emails", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__show_groups", + "description": "Get a list of groups you are a member of.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "delete": { + "default": false, + "description": "Filter groups by whether they are deleted or not.", + "title": "Delete", + "type": "boolean" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 9223372036854775807, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__show_group", + "description": "Show group details based on its ID.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "group_id": { + "description": "The ID of the group.", + "title": "Group Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "group_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__update_group", + "description": "Update group information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "name": { + "default": null, + "description": "The updated name of the group.", + "minLength": 1, + "title": "Name", + "type": "string" + }, + "description": { + "default": null, + "description": "The updated description of the group.", + "title": "Description", + "type": "string" + }, + "group_id": { + "description": "The ID of the group.", + "title": "Group Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "group_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__delete_group", + "description": "Delete a group you are a member of. Any member can undelete it later.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "group_id": { + "description": "The ID of the group to delete.", + "title": "Group Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "group_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__undelete_group", + "description": "Undelete a group you are a member of.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "group_id": { + "description": "The ID of the group to undelete.", + "title": "Group Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "group_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__add_member_to_group", + "description": "Add a member to a group.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "user_email": { + "description": "Email of the user to add to the group.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "User Email", + "type": "string" + }, + "group_id": { + "description": "The ID of the group.", + "title": "Group Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "user_email", + "group_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__remove_member_from_group", + "description": "Remove a member from a group.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "group_id": { + "description": "The ID of the group.", + "title": "Group Id", + "type": "integer" + }, + "user_email": { + "description": "Email of the user to remove from the group.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "User Email", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "group_id", + "user_email", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__exit_group", + "description": "Exit from a group you are a part of.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "group_id": { + "description": "The ID of the group to exit.", + "title": "Group Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "group_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__regenerate_invitation_code", + "description": "Regenerate the invitation code for a group. Anyone with this code can join the group. The old code will be invalidated.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "group_id": { + "description": "The ID of the group.", + "title": "Group Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "group_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__accept_group_invitation", + "description": "Accept a group invitation shared with you by one of its members.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "invitation_code": { + "description": "The group invitation shared with you by one of its members.", + "title": "Invitation Code", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "invitation_code", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__record_expense", + "description": "Record a new expense to share with others.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "description": { + "description": "A short note or description of the expense.", + "title": "Description", + "type": "string" + }, + "paid_amount": { + "description": "The total amount of the expense paid.", + "title": "Paid Amount", + "type": "number" + }, + "payer_email": { + "description": "Email of the user who paid for the expense.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Payer Email", + "type": "string" + }, + "debtor_emails": { + "description": "Emails of the users who owe a share of the expense.", + "items": { + "type": "string" + }, + "title": "Debtor Emails", + "type": "array" + }, + "debt_amounts": { + "default": null, + "description": "The amounts owed by each debtor. If not passed, it is assumed that each debtor owes an equal share. If passed, the list must be of the same length as debtor_ids.", + "items": { + "type": "number" + }, + "title": "Debt Amounts", + "type": "array" + }, + "group_id": { + "default": null, + "description": "The ID of the group this expense should belong to. Keep it none if it should not belong to any group.", + "title": "Group Id", + "type": "integer" + }, + "receipt_file_path": { + "default": null, + "description": "Absolute file path (starting with /) from the file_system app to attach as a receipt for this expense.", + "title": "Receipt File Path", + "type": "string" + }, + "file_system_access_token": { + "default": null, + "description": "Access token obtained from file_system app login. Only needed for attaching receipt file.", + "title": "File System Access Token", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "description", + "paid_amount", + "payer_email", + "debtor_emails", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__attach_expense_receipt_file", + "description": "Attach a receipt file to an expense.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "receipt_file_path": { + "description": "Absolute file path (starting with /) from the file_system app to attach as a receipt for this expense.", + "title": "Receipt File Path", + "type": "string" + }, + "overwrite": { + "default": false, + "description": "Whether to overwrite the existing receipt file if it already exists.", + "title": "Overwrite", + "type": "boolean" + }, + "file_system_access_token": { + "description": "Access token obtained from file_system app login.", + "title": "File System Access Token", + "type": "string" + }, + "expense_id": { + "description": "The ID of the expense to attach receipt file to.", + "title": "Expense Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "receipt_file_path", + "file_system_access_token", + "expense_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__delete_expense_receipt_file", + "description": "Delete the receipt file from an expense.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "expense_id": { + "description": "The ID of the expense to detach receipt file from.", + "title": "Expense Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "expense_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__download_expense_receipt_file", + "description": "Download a receipt file attachment for an expense.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "expense_id": { + "description": "The ID of the expense to download receipt for.", + "title": "Expense Id", + "type": "integer" + }, + "file_system_access_token": { + "description": "Access token obtained from file_system app login.", + "title": "File System Access Token", + "type": "string" + }, + "download_to_file_path": { + "default": null, + "description": "The file path to download the receipt file to in file system app. Path can be absolute, starting with '/', or relative to the user's home directory, starting with '~/'. If not passed, it will be saved in your ~/downloads directory.", + "title": "Download To File Path", + "type": "string" + }, + "overwrite": { + "default": false, + "description": "Whether to overwrite the file if it already exists.", + "title": "Overwrite", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "expense_id", + "file_system_access_token", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__show_expense", + "description": "Show detailed expense information based on its ID.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "expense_id": { + "description": "The ID of the expense.", + "title": "Expense Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "expense_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__update_expense", + "description": "Update expense information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "description": { + "default": null, + "description": "The updated note or description of the expense.", + "title": "Description", + "type": "string" + }, + "paid_amount": { + "default": null, + "description": "The updated total paid amount of the expense.", + "title": "Paid Amount", + "type": "number" + }, + "payer_email": { + "default": null, + "description": "Email of updated user who paid for the expense.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Payer Email", + "type": "string" + }, + "debtor_emails": { + "default": null, + "description": "Emails of updated users who owe a share of the expense.", + "items": { + "type": "string" + }, + "title": "Debtor Emails", + "type": "array" + }, + "debt_amounts": { + "default": null, + "description": "The updated amounts owed by each debtor. If debtor_ids is passed and debt_amounts is not passed, each debtor will owe an equal share. If debtor_ids is passed and debt_amounts is passed, the list must be of the same length as debtor_ids.If debt_amount is passed, debtor_ids must be passed.", + "items": { + "type": "number" + }, + "title": "Debt Amounts", + "type": "array" + }, + "expense_id": { + "description": "The ID of the expense.", + "title": "Expense Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "expense_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__delete_expense", + "description": "Mark the expense you are involved in as deleted. Anyone involved in the expense or a member of its group can undelete it later.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "expense_id": { + "description": "The ID of the expense to delete.", + "title": "Expense Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "expense_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__show_group_expenses", + "description": "Show or search your expenses from a given group based on various criteria.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "group_id": { + "description": "The ID of the group to filter expenses.", + "title": "Group Id", + "type": "integer" + }, + "query": { + "default": "", + "description": "The search query string.", + "title": "Query", + "type": "string" + }, + "participant_email": { + "default": null, + "description": "Email of the payer or debtors to filter expenses.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Participant Email", + "type": "string" + }, + "min_amount": { + "default": 0.0, + "description": "Filter expenses by minimum amount.", + "minimum": 0.0, + "title": "Min Amount", + "type": "number" + }, + "max_amount": { + "default": 9.223372036854776e+18, + "description": "Filter expenses by maximum amount.", + "minimum": 0.0, + "title": "Max Amount", + "type": "number" + }, + "min_created_at": { + "default": "1500-01-01", + "description": "Filter emails by minimum created_at date in YYYY-MM-DD format.", + "title": "Min Created At", + "type": "string" + }, + "max_created_at": { + "default": "3000-01-01", + "description": "Filter emails by maximum created_at date in YYYY-MM-DD format.", + "title": "Max Created At", + "type": "string" + }, + "deleted": { + "default": false, + "description": "Whether to limit the results to expenses marked as deleted or not.", + "title": "Deleted", + "type": "boolean" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the expenses by prefixed with +/- to reflect ascending/descending. Valid attributes: created_at and amount. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page. If both query and sort_by are not given, null, or empty, sort_by will default to -created_at.", + "title": "Sort By", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "group_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__show_no_group_expenses", + "description": "Show or search your expenses that are not part of any group based on various criteria.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "The search query string.", + "title": "Query", + "type": "string" + }, + "participant_email": { + "default": null, + "description": "Email of the payer or debtors to filter expenses.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Participant Email", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "min_amount": { + "default": 0.0, + "description": "Filter expenses by minimum amount.", + "minimum": 0.0, + "title": "Min Amount", + "type": "number" + }, + "max_amount": { + "default": 9.223372036854776e+18, + "description": "Filter expenses by maximum amount.", + "minimum": 0.0, + "title": "Max Amount", + "type": "number" + }, + "min_created_at": { + "default": "1500-01-01", + "description": "Filter emails by minimum created_at date in YYYY-MM-DD format.", + "title": "Min Created At", + "type": "string" + }, + "max_created_at": { + "default": "3000-01-01", + "description": "Filter emails by maximum created_at date in YYYY-MM-DD format.", + "title": "Max Created At", + "type": "string" + }, + "deleted": { + "default": false, + "description": "Whether to limit the results to expenses marked as deleted or not.", + "title": "Deleted", + "type": "boolean" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the expenses by prefixed with +/- to reflect ascending/descending. Valid attributes: created_at. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page. If both query and sort_by are not given, null, or empty, sort_by will default to -created_at.", + "title": "Sort By", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__undelete_expense", + "description": "Restore a previously deleted expense you are involved in.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "expense_id": { + "description": "The ID of the expense to undelete.", + "title": "Expense Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "expense_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__post_expense_comment", + "description": "Post a a comment on an expense.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "comment": { + "description": "The comment for the expense.", + "maxLength": 1000, + "minLength": 1, + "title": "Comment", + "type": "string" + }, + "expense_id": { + "description": "The ID of the expense.", + "title": "Expense Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "comment", + "expense_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__show_expense_comments", + "description": "Get a list of expense comments.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "expense_id": { + "description": "The ID of the expense.", + "title": "Expense Id", + "type": "integer" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "expense_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__update_expense_comment", + "description": "Update a comment you posted on an expense.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "comment": { + "description": "The updated comment for the expense.", + "minLength": 1, + "title": "Comment", + "type": "string" + }, + "comment_id": { + "description": "The ID of the expense comment to update.", + "title": "Comment Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "comment", + "comment_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__delete_expense_comment", + "description": "Delete a comment you posted on an expense.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "comment_id": { + "description": "The ID of the expense comment to delete.", + "title": "Comment Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "comment_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__show_expense_comment", + "description": "Get information about a comment posted on an expense.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "comment_id": { + "description": "The ID of the expense comment.", + "title": "Comment Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "comment_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__record_payment", + "description": "Record a new payment for an expense. This only records payment on splitwise and does not move real money.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "group_id": { + "default": null, + "description": "The ID of the group to record the payment in.", + "title": "Group Id", + "type": "integer" + }, + "payer_email": { + "description": "Email of the user who made the payment.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Payer Email", + "type": "string" + }, + "receiver_email": { + "description": "Email of the user who received the payment.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Receiver Email", + "type": "string" + }, + "amount": { + "description": "The amount of the payment.", + "title": "Amount", + "type": "number" + }, + "description": { + "default": null, + "description": "A short note or description of the payment.", + "title": "Description", + "type": "string" + }, + "receipt_file_path": { + "default": null, + "description": "Receipt file path to attach as an evidence of this payment made (e.g., snapshot of bank transfer, venmo, etc.)", + "title": "Receipt File Path", + "type": "string" + }, + "file_system_access_token": { + "default": null, + "description": "Access token obtained from file_system app login. Only needed for attaching receipt file.", + "title": "File System Access Token", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "payer_email", + "receiver_email", + "amount", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__attach_payment_receipt_file", + "description": "Attach a receipt file to a payment.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "receipt_file_path": { + "description": "The file path of the receipt file to attach to this payment.", + "title": "Receipt File Path", + "type": "string" + }, + "overwrite": { + "default": false, + "description": "Whether to overwrite the existing receipt file if it already exists.", + "title": "Overwrite", + "type": "boolean" + }, + "file_system_access_token": { + "description": "Access token obtained from file_system app login.", + "title": "File System Access Token", + "type": "string" + }, + "payment_id": { + "description": "The ID of the payment to attach receipt to.", + "title": "Payment Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "receipt_file_path", + "file_system_access_token", + "payment_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__delete_payment_receipt_file", + "description": "Delete the receipt file from a payment.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "payment_id": { + "description": "The ID of the payment to delete receipt for.", + "title": "Payment Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "payment_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__download_payment_receipt_file", + "description": "Download receipt file attachment for a payment.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "payment_id": { + "description": "The ID of the payment to download receipt for.", + "title": "Payment Id", + "type": "integer" + }, + "download_to_file_path": { + "default": null, + "description": "The file path to download the receipt file to in file system app. Path can be absolute, starting with '/', or relative to the user's home directory, starting with '~/'. If not passed, it will be saved in your ~/downloads directory.", + "title": "Download To File Path", + "type": "string" + }, + "overwrite": { + "default": false, + "description": "Whether to overwrite the file if it already exists.", + "title": "Overwrite", + "type": "boolean" + }, + "file_system_access_token": { + "description": "Access token obtained from file_system app login.", + "title": "File System Access Token", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "payment_id", + "file_system_access_token", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__show_payment", + "description": "Show payment details based on its ID.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "payment_id": { + "description": "The ID of the payment.", + "title": "Payment Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "payment_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__update_payment", + "description": "Update payment information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "amount": { + "default": null, + "description": "The updated amount of the payment.", + "title": "Amount", + "type": "number" + }, + "description": { + "default": null, + "description": "The updated note or description of the payment.", + "title": "Description", + "type": "string" + }, + "payment_id": { + "description": "The ID of the payment.", + "title": "Payment Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "payment_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__delete_payment", + "description": "Mark the payment you are involved in as deleted. Anyone involved in the payment or a member of its group can undelete it later.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "payment_id": { + "description": "The ID of the payment to delete.", + "title": "Payment Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "payment_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__show_group_payments", + "description": "Search or show the payments that are part of the given group.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "group_id": { + "description": "The ID of the group to filter payments.", + "title": "Group Id", + "type": "integer" + }, + "query": { + "default": "", + "description": "The search query string.", + "title": "Query", + "type": "string" + }, + "participant_email": { + "default": null, + "description": "Email of the payer or receiver to filter payments.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Participant Email", + "type": "string" + }, + "min_amount": { + "default": 0.0, + "description": "Filter expenses by minimum amount.", + "minimum": 0.0, + "title": "Min Amount", + "type": "number" + }, + "max_amount": { + "default": 9.223372036854776e+18, + "description": "Filter expenses by maximum amount.", + "minimum": 0.0, + "title": "Max Amount", + "type": "number" + }, + "min_created_at": { + "default": "1500-01-01", + "description": "Filter emails by minimum created_at date in YYYY-MM-DD format.", + "title": "Min Created At", + "type": "string" + }, + "max_created_at": { + "default": "3000-01-01", + "description": "Filter emails by maximum created_at date in YYYY-MM-DD format.", + "title": "Max Created At", + "type": "string" + }, + "deleted": { + "default": false, + "description": "Filter expenses by deleted status.", + "title": "Deleted", + "type": "boolean" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the payments by prefixed with +/- to reflect ascending/descending. Valid attributes: created_at and amount. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page. If both query and sort_by are not given, null, or empty, sort_by will default to -created_at.", + "title": "Sort By", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "group_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__show_no_group_payments", + "description": "Search or show payments you are involved in that are not part of any group.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "The search query string.", + "title": "Query", + "type": "string" + }, + "participant_email": { + "default": null, + "description": "Email of the payer or receiver to filter payments.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Participant Email", + "type": "string" + }, + "min_amount": { + "default": 0.0, + "description": "Filter expenses by minimum amount.", + "minimum": 0.0, + "title": "Min Amount", + "type": "number" + }, + "max_amount": { + "default": 9.223372036854776e+18, + "description": "Filter expenses by maximum amount.", + "minimum": 0.0, + "title": "Max Amount", + "type": "number" + }, + "min_created_at": { + "default": "1500-01-01", + "description": "Filter emails by minimum created_at date in YYYY-MM-DD format.", + "title": "Min Created At", + "type": "string" + }, + "max_created_at": { + "default": "3000-01-01", + "description": "Filter emails by maximum created_at date in YYYY-MM-DD format.", + "title": "Max Created At", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "deleted": { + "default": false, + "description": "Filter payments by deleted status.", + "title": "Deleted", + "type": "boolean" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the payments by prefixed with +/- to reflect ascending/descending. Valid attributes: created_at and amount. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page. If both query and sort_by are not given, null, or empty, sort_by will default to -created_at.", + "title": "Sort By", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__undelete_payment", + "description": "Restore a previously deleted payment you are involved in.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "payment_id": { + "description": "The ID of the payment to delete.", + "title": "Payment Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "payment_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__show_person_balance", + "description": "Show the amounts you and a given person owe to each other, broken down by group. The non-grouped expenses/payments will be shown as a group with ID None.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "The email of the person to show your balance with.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "email", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__show_people_balance", + "description": "Show the aggregate amounts you owe to each person and they owe to you.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__show_group_balance", + "description": "For the given group, show the detailed breakdown of amounts different members owe to each other. If group_id is not passed, show the amounts you owe to others and vice-versa for non-grouped expenses/payments.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "group_id": { + "default": null, + "description": "The ID of the group to show balance of. If not passed, it will show balance from non-grouped expenses/payments.", + "title": "Group Id", + "type": "integer" + }, + "email": { + "default": null, + "description": "If passed, only balance of this member of the group will be shown. Otherwise, balance of all members will be shown. If group_id is not passed, email can only be your own email or None.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__show_groups_balance", + "description": "Show the aggregate amounts you owe to others or others owe you for each group you are member of. The non-grouped expenses/payments will be shown as a group with group_id of None.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__settle_up", + "description": "Settle up outstanding balance with a user in a group. This only records payment on splitwise and does not move real money.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "group_id": { + "default": null, + "description": "The ID of the group to settle balance.", + "title": "Group Id", + "type": "integer" + }, + "email": { + "description": "The email of the user to settle balance with.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + }, + "description": { + "default": "Settle up balance.", + "description": "The description of the payment.", + "title": "Description", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "email", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__post_payment_comment", + "description": "Create a new payment comment.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "comment": { + "description": "The comment for the payment.", + "title": "Comment", + "type": "string" + }, + "payment_id": { + "description": "The ID of the payment.", + "title": "Payment Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "comment", + "payment_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__show_payment_comments", + "description": "Get a list of payment comments.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "payment_id": { + "description": "The ID of the payment.", + "title": "Payment Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "payment_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__update_payment_comment", + "description": "Update a payment comment.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "comment": { + "description": "The updated comment for the payment.", + "minLength": 1, + "title": "Comment", + "type": "string" + }, + "comment_id": { + "description": "The ID of the payment comment to update.", + "title": "Comment Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "comment", + "comment_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__delete_payment_comment", + "description": "Delete a comment you posted on a payment.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "comment_id": { + "description": "The ID of the payment comment to delete.", + "title": "Comment Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "comment_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__show_payment_comment", + "description": "Get information about a comment posted on a payment.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "comment_id": { + "description": "The ID of the payment comment.", + "title": "Comment Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "comment_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__show_notifications", + "description": "Get a list of your notifications.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "read": { + "default": null, + "description": "Filter notifications by read status. Will skip filtering if not passed.", + "title": "Read", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__delete_notifications", + "description": "Delete all of your notifications.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__mark_notifications", + "description": "Mark all notifications as read or unread.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "read": { + "description": "Read status of the notification.", + "title": "Read", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "read", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__show_notifications_count", + "description": "Get the count of your notifications.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "read": { + "default": null, + "description": "Count notifications by read status. Will count all if not passed.", + "title": "Read", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__delete_notification", + "description": "Delete a notification.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "notification_id": { + "description": "The ID of the notification.", + "title": "Notification Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "notification_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "splitwise__mark_notification", + "description": "Mark a notification as read or unread.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "read": { + "description": "Mark the notification as read or unread.", + "title": "Read", + "type": "boolean" + }, + "notification_id": { + "description": "ID of the notification to mark.", + "title": "Notification Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from splitwise app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "read", + "notification_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "simple_note__show_account", + "description": "Show your account information. Unlike show_profile, this includes private information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from simple_note app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "simple_note__signup", + "description": "Sign up to create account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "first_name": { + "description": "Your first name", + "minLength": 1, + "title": "First Name", + "type": "string" + }, + "last_name": { + "description": "Your last name", + "minLength": 1, + "title": "Last Name", + "type": "string" + }, + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + }, + "password": { + "description": "Your password.", + "minLength": 5, + "title": "Password", + "type": "string" + } + }, + "required": [ + "first_name", + "last_name", + "email", + "password" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "simple_note__delete_account", + "description": "Delete your account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from simple_note app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "simple_note__update_account_name", + "description": "Update your first or last name in the account profile.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "first_name": { + "default": null, + "description": "Your updated first name.", + "minLength": 1, + "title": "First Name", + "type": "string" + }, + "last_name": { + "default": null, + "description": "Your updated last name.", + "minLength": 1, + "title": "Last Name", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from simple_note app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "simple_note__login", + "description": "Login to your account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "username": { + "description": "Your account email.", + "title": "Username", + "type": "string" + }, + "password": { + "description": "Your account password.", + "title": "Password", + "type": "string" + } + }, + "required": [ + "username", + "password" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "simple_note__logout", + "description": "Logout from your account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from simple_note app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "simple_note__send_verification_code", + "description": "Send account verification code to your email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + } + }, + "required": [ + "email" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "simple_note__verify_account", + "description": "Verify your account using the verification code sent to your email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + }, + "verification_code": { + "description": "The verification code sent to your email address.", + "title": "Verification Code", + "type": "string" + } + }, + "required": [ + "email", + "verification_code" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "simple_note__send_password_reset_code", + "description": "Send password reset code to your email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + } + }, + "required": [ + "email" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "simple_note__reset_password", + "description": "Reset your password using the password reset code sent to your email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + }, + "password_reset_code": { + "description": "The password reset code sent to your email address.", + "title": "Password Reset Code", + "type": "string" + }, + "new_password": { + "description": "Your new password.", + "minLength": 5, + "title": "New Password", + "type": "string" + } + }, + "required": [ + "email", + "password_reset_code", + "new_password" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "simple_note__show_profile", + "description": "Show public profile information of a user.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "default": null, + "description": "Email of the person you want to see the profile information of.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + } + }, + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "simple_note__search_notes", + "description": "Search your notes. This will not show contents of the notes. Pinned notes will be shown first by default, except when dont_reorder_pinned is true.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "Search query for the notes.", + "title": "Query", + "type": "string" + }, + "tags": { + "default": null, + "description": "Tags to filter the notes by. Notes not having any of these tags will be filtered out.", + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, + "pinned": { + "default": null, + "description": "Filter notes by pinned status.", + "title": "Pinned", + "type": "boolean" + }, + "dont_reorder_pinned": { + "default": null, + "description": "If true, pinned notes will not be reordered to be shown at the top.", + "title": "Dont Reorder Pinned", + "type": "boolean" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the notes by prefixed with +/- to reflect ascending/descending. Valid attributes: created_at and updated_at. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page. If both query and sort_by are not given, null, or empty, sort_by will default to -updated_at.", + "title": "Sort By", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from simple_note app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "simple_note__create_note", + "description": "Create a new note.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "title": { + "description": "Title of the note", + "minLength": 1, + "title": "Title", + "type": "string" + }, + "content": { + "description": "Content of the note", + "title": "Content", + "type": "string" + }, + "tags": { + "default": null, + "description": "Tags for the note.", + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, + "pinned": { + "default": false, + "description": "Pinned status of the note.", + "title": "Pinned", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from simple_note app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "title", + "content", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "simple_note__show_note", + "description": "Show detailed information of a note, including its content.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "note_id": { + "description": "ID of the note to be shown.", + "title": "Note Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from simple_note app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "note_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "simple_note__update_note", + "description": "Update a note's title, content, tags, and/or pinned status.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "title": { + "default": null, + "description": "Updated title of the note.", + "minLength": 1, + "title": "Title", + "type": "string" + }, + "content": { + "default": null, + "description": "Updated content of the note.", + "title": "Content", + "type": "string" + }, + "tags": { + "default": null, + "description": "Updated tags for the note.", + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, + "pinned": { + "default": null, + "description": "Updated pinned status of the note.", + "title": "Pinned", + "type": "boolean" + }, + "note_id": { + "description": "ID of the note to update.", + "title": "Note Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from simple_note app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "note_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "simple_note__delete_note", + "description": "Delete a note.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "note_id": { + "description": "ID of the note to be deleted.", + "title": "Note Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from simple_note app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "note_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "simple_note__add_content_to_note", + "description": "Append or prepend content to a note.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "added_content": { + "description": "Content to append or prepend on the note's existing content. It will add a new line between the existing content and the added content.", + "title": "Added Content", + "type": "string" + }, + "note_id": { + "description": "ID of the note to add content to.", + "title": "Note Id", + "type": "integer" + }, + "append_or_prepend": { + "description": "Whether to append or prepend the content.", + "enum": [ + "append", + "prepend" + ], + "title": "Append Or Prepend", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from simple_note app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "added_content", + "note_id", + "append_or_prepend", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__show_account", + "description": "Show your account information. Unlike show_profile, this includes private information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__signup", + "description": "Sign up to create account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "first_name": { + "description": "Your first name.", + "minLength": 1, + "title": "First Name", + "type": "string" + }, + "last_name": { + "description": "Your last name.", + "minLength": 1, + "title": "Last Name", + "type": "string" + }, + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + }, + "password": { + "description": "Your password.", + "minLength": 5, + "title": "Password", + "type": "string" + } + }, + "required": [ + "first_name", + "last_name", + "email", + "password" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__delete_account", + "description": "Delete your account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__update_account_name", + "description": "Update your first or last name in the account profile.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "first_name": { + "default": null, + "description": "Your updated first name.", + "minLength": 1, + "title": "First Name", + "type": "string" + }, + "last_name": { + "default": null, + "description": "Your updated last name.", + "minLength": 1, + "title": "Last Name", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__login", + "description": "Login to your account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "username": { + "description": "Your account email.", + "title": "Username", + "type": "string" + }, + "password": { + "description": "Your account password.", + "title": "Password", + "type": "string" + } + }, + "required": [ + "username", + "password" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__logout", + "description": "Logout from your account.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__send_verification_code", + "description": "Send account verification code to your email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + } + }, + "required": [ + "email" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__verify_account", + "description": "Verify your account using the verification code sent to your email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + }, + "verification_code": { + "description": "The verification code sent to your email address.", + "title": "Verification Code", + "type": "string" + } + }, + "required": [ + "email", + "verification_code" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__send_password_reset_code", + "description": "Send password reset code to your email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + } + }, + "required": [ + "email" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__reset_password", + "description": "Reset your password using the password reset code sent to your email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "Your email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + }, + "password_reset_code": { + "description": "The password reset code sent to your email address.", + "title": "Password Reset Code", + "type": "string" + }, + "new_password": { + "description": "Your new password.", + "minLength": 5, + "title": "New Password", + "type": "string" + } + }, + "required": [ + "email", + "password_reset_code", + "new_password" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__show_profile", + "description": "Show public profile information of a user.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "default": null, + "description": "Email of the person you want to see the profile information of.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + } + }, + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__search_users", + "description": "Search Todoist users by name or email address.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "Search query string.", + "title": "Query", + "type": "string" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + } + }, + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__create_project", + "description": "Create a new project.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "name": { + "description": "The name of the project.", + "minLength": 1, + "title": "Name", + "type": "string" + }, + "color": { + "default": "charcoal", + "description": "Color of the project.", + "enum": [ + "charcoal", + "red", + "blue", + "green", + "orange", + "yellow" + ], + "title": "Color", + "type": "string" + }, + "description": { + "default": "", + "description": "The description of the project.", + "title": "Description", + "type": "string" + }, + "is_favorite": { + "default": false, + "description": "Whether the project is marked as favorite.", + "title": "Is Favorite", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "name", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__show_projects", + "description": "Show or search a list of your projects.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "The search query for projects.", + "title": "Query", + "type": "string" + }, + "color": { + "default": null, + "description": "The color of the project to filter by.", + "enum": [ + "charcoal", + "red", + "blue", + "green", + "orange", + "yellow" + ], + "title": "Color", + "type": "string" + }, + "is_favorite": { + "default": null, + "description": "Project's favorite status to filter by.", + "title": "Is Favorite", + "type": "boolean" + }, + "is_archived": { + "default": null, + "description": "Project's archival status to filter by.", + "title": "Is Archived", + "type": "boolean" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 9223372036854775807, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "sort_by": { + "default": null, + "description": "The attribute to sort the projects by prefixed with +/- to reflect ascending/descending. Valid attributes: created_at. If both query and sort_by are given and non-empty, results will be first ranked by query relevance, then paginated, and will then be sorted by the given attribute within each page. If both query and sort_by are not given, null, or empty, sort_by will default to -created_at.", + "title": "Sort By", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__show_project", + "description": "Show project details based on its ID.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "project_id": { + "description": "The ID of the project. You can use 0 for your Inbox project.", + "title": "Project Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "project_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__update_project", + "description": "Update project information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "name": { + "default": null, + "description": "The updated name of the project.", + "minLength": 1, + "title": "Name", + "type": "string" + }, + "color": { + "default": null, + "description": "The updated color of the project.", + "enum": [ + "charcoal", + "red", + "blue", + "green", + "orange", + "yellow" + ], + "title": "Color", + "type": "string" + }, + "description": { + "default": null, + "description": "The updated description of the project.", + "title": "Description", + "type": "string" + }, + "is_favorite": { + "default": null, + "description": "Whether the project is marked as favorite.", + "title": "Is Favorite", + "type": "boolean" + }, + "is_archived": { + "default": null, + "description": "Whether the project is marked as archived.", + "title": "Is Archived", + "type": "boolean" + }, + "project_id": { + "description": "The ID of the project. You can use 0 for your Inbox project.", + "title": "Project Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "project_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__delete_project", + "description": "Delete a project.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "project_id": { + "description": "The ID of the project to delete.", + "title": "Project Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "project_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__create_section", + "description": "Create a new section within a project.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "name": { + "description": "The name of the section.", + "minLength": 1, + "title": "Name", + "type": "string" + }, + "order_index": { + "default": -1, + "description": "Index at which this section should be inserted in the current list of sections. 0 to insert at the top of the list, 1 to insert below the first section, etc. You can also use a negative value to insert from the end: -1 to insert at the end, -2 to insert just above the last section, etc.", + "title": "Order Index", + "type": "integer" + }, + "project_id": { + "description": "The ID of the project. You can use 0 for your Inbox project.", + "title": "Project Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "name", + "project_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__show_sections", + "description": "Get a list of sections within a project.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "project_id": { + "description": "The ID of the project. You can use 0 for your Inbox project.", + "title": "Project Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "project_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__update_section", + "description": "Update section information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "name": { + "default": null, + "description": "The updated name of the section.", + "minLength": 1, + "title": "Name", + "type": "string" + }, + "order_index": { + "default": null, + "description": "The updated order index of this section in the list of project sections.", + "title": "Order Index", + "type": "integer" + }, + "section_id": { + "description": "The ID of the section.", + "title": "Section Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "section_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__delete_section", + "description": "Delete a section within a project.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "section_id": { + "description": "The ID of the section to delete.", + "title": "Section Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "section_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__remove_collaborator_from_project", + "description": "Remove a collaborator (or self) from a project.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "project_id": { + "description": "The ID of the project.", + "title": "Project Id", + "type": "integer" + }, + "email": { + "description": "Email of the user to remove as a collaborator.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "project_id", + "email", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__send_project_invite", + "description": "Invite a collaborator to join a project. They will be notified via email.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "email": { + "description": "Email of the user to invite as a collaborator.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Email", + "type": "string" + }, + "project_id": { + "description": "The ID of the project.", + "title": "Project Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "email", + "project_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__accept_project_invite", + "description": "Accept an invite to join a project using an invite code generated by a project collaborator.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "invite_code": { + "description": "The invite code sent to you by a project collaborator.", + "title": "Invite Code", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "invite_code", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__delete_project_invite", + "description": "Delete a project invite that you sent or received.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "invite_code": { + "description": "The invite code sent to you by a project collaborator.", + "title": "Invite Code", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "invite_code", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__create_task", + "description": "Create a new task within a project.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "section_id": { + "default": null, + "description": "The ID of the section within the project.", + "title": "Section Id", + "type": "integer" + }, + "title": { + "description": "The title of the task.", + "minLength": 1, + "title": "Title", + "type": "string" + }, + "description": { + "default": "", + "description": "The description of the task.", + "title": "Description", + "type": "string" + }, + "due_date": { + "default": null, + "description": "The due date of the task in YYYY-MM-DD format.", + "title": "Due Date", + "type": "string" + }, + "duration": { + "default": null, + "description": "The duration of the task.", + "minimum": 0.0, + "title": "Duration", + "type": "number" + }, + "duration_unit": { + "default": null, + "description": "The unit of the task duration.", + "enum": [ + "minutes", + "hours", + "days" + ], + "title": "Duration Unit", + "type": "string" + }, + "order_index": { + "default": -1, + "description": "Index at which this task should be inserted in the current list of tasks. 0 to insert at the top of the list, 1 to insert below the first task, etc. You can also use a negative value to insert from the end: -1 to insert at the end, -2 to insert just above the last task, etc.", + "title": "Order Index", + "type": "integer" + }, + "priority": { + "default": "medium", + "description": "The priority of the task.", + "enum": [ + "high", + "medium", + "low" + ], + "title": "Priority", + "type": "string" + }, + "project_id": { + "description": "The ID of the project. If set to 0, the task will be created in your default/inbox project.", + "title": "Project Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "title", + "project_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__show_tasks", + "description": "Get a list of tasks within a project.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "project_id": { + "description": "The ID of the project. You can use 0 for your Inbox project.", + "title": "Project Id", + "type": "integer" + }, + "section_id": { + "default": null, + "description": "The ID of the section to filter tasks by. Set it to 0 for tasks without a section.", + "title": "Section Id", + "type": "integer" + }, + "assignee_email": { + "default": null, + "description": "Email address of the assignee to filter tasks by.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Assignee Email", + "type": "string" + }, + "assigner_email": { + "default": null, + "description": "Email address of the assigner to filter tasks by.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Assigner Email", + "type": "string" + }, + "priority": { + "default": null, + "description": "The priority to filter tasks by.", + "enum": [ + "high", + "medium", + "low" + ], + "title": "Priority", + "type": "string" + }, + "is_completed": { + "default": null, + "description": "Completion status to filter by.", + "title": "Is Completed", + "type": "boolean" + }, + "due_today": { + "default": false, + "description": "If true, only tasks due today will be returned. If false, all tasks will be returned.", + "title": "Due Today", + "type": "boolean" + }, + "label_id": { + "default": null, + "description": "The ID of the label to filter tasks by.", + "title": "Label Id", + "type": "integer" + }, + "overdue": { + "default": false, + "description": "If true, only tasks that are overdue will be returned. If false, all tasks will be returned.", + "title": "Overdue", + "type": "boolean" + }, + "min_due_date": { + "default": "1500-01-01", + "description": "Only tasks with a due date greater than or equal to this date will be returned. Date should be in YYYY-MM-DD format.", + "title": "Min Due Date", + "type": "string" + }, + "max_due_date": { + "default": "3000-01-01", + "description": "Only tasks with a due date less than or equal to this date will be returned. Date should be in YYYY-MM-DD format.", + "title": "Max Due Date", + "type": "string" + }, + "sort_by": { + "default": "+order_index", + "description": "The attribute to sort the tasks by prefixed with +/- to reflect ascending/descending. Valid attributes: order_index, due_date and priority.", + "title": "Sort By", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "project_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__assign_or_unassign_task", + "description": "Assign or unassign a task to a user. If assignee_email is null, the task will be unassigned.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "assignee_email": { + "default": null, + "description": "Email of the user to assign the task to.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "title": "Assignee Email", + "type": "string" + }, + "task_id": { + "description": "The ID of the task.", + "title": "Task Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "task_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__show_task", + "description": "Show detailed information about the task.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "task_id": { + "description": "The ID of the task.", + "title": "Task Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "task_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__update_task", + "description": "Update task information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "title": { + "default": null, + "description": "The updated title of the task.", + "minLength": 1, + "title": "Title", + "type": "string" + }, + "description": { + "default": null, + "description": "The updated description of the task.", + "title": "Description", + "type": "string" + }, + "due_date": { + "default": null, + "description": "The updated due date of the task in YYYY-MM-DD format. Use 'None' (string) to remove the due date as leaving it empty or null is to not change the due date.", + "title": "Due Date", + "type": "string" + }, + "duration": { + "default": null, + "description": "The updated duration of the task. Use 0 to remove the duration (and duration_unit) as leaving it empty or null is to not change the duration.", + "minimum": 0.0, + "title": "Duration", + "type": "number" + }, + "duration_unit": { + "default": null, + "description": "The updated unit of the task duration.", + "enum": [ + "minutes", + "hours", + "days" + ], + "title": "Duration Unit", + "type": "string" + }, + "is_completed": { + "default": null, + "description": "Whether the task is completed.", + "title": "Is Completed", + "type": "boolean" + }, + "order_index": { + "default": null, + "description": "The updated order index of this task in the list of section tasks.", + "title": "Order Index", + "type": "integer" + }, + "priority": { + "default": null, + "description": "The updated priority of the task.", + "enum": [ + "high", + "medium", + "low" + ], + "title": "Priority", + "type": "string" + }, + "task_id": { + "description": "The ID of the task.", + "title": "Task Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "task_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__delete_task", + "description": "Delete a task.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "task_id": { + "description": "The ID of the task to delete.", + "title": "Task Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "task_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__create_sub_task", + "description": "Create a new sub_task within a task.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "title": { + "description": "The title of the sub_task.", + "minLength": 1, + "title": "Title", + "type": "string" + }, + "description": { + "default": "", + "description": "The description of the sub_task.", + "title": "Description", + "type": "string" + }, + "due_date": { + "default": null, + "description": "The due date of the sub_task in YYYY-MM-DD format.", + "title": "Due Date", + "type": "string" + }, + "duration": { + "default": null, + "description": "The duration of the sub_task.", + "minimum": 0.0, + "title": "Duration", + "type": "number" + }, + "duration_unit": { + "default": null, + "description": "The unit of the sub_task duration.", + "enum": [ + "minutes", + "hours", + "days" + ], + "title": "Duration Unit", + "type": "string" + }, + "priority": { + "default": "medium", + "description": "The priority of the sub_task.", + "enum": [ + "high", + "medium", + "low" + ], + "title": "Priority", + "type": "string" + }, + "order_index": { + "default": -1, + "description": "Index at which this sub_task should be inserted in the current list of sub_tasks. 0 to insert at the top of the list, 1 to insert below the first sub_task, etc. You can also use a negative value to insert from the end: -1 to insert at the end, -2 to insert just above the last sub_task, etc.", + "title": "Order Index", + "type": "integer" + }, + "task_id": { + "description": "The ID of the task.", + "title": "Task Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "title", + "task_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__show_sub_tasks", + "description": "Get a list of sub_tasks within a task.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "task_id": { + "description": "The ID of the task.", + "title": "Task Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "task_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__update_sub_task", + "description": "Update sub_task information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "title": { + "default": null, + "description": "The updated title of the sub_task.", + "minLength": 1, + "title": "Title", + "type": "string" + }, + "description": { + "default": null, + "description": "The updated description of the sub_task.", + "title": "Description", + "type": "string" + }, + "due_date": { + "default": null, + "description": "The updated due date of the sub_task in YYYY-MM-DD format. Use 'None' (string) to remove the due date as leaving it empty or null is to not change the due date.", + "title": "Due Date", + "type": "string" + }, + "duration": { + "default": null, + "description": "The updated duration of the sub_task. Use 0 to remove the duration (and duration_unit) as leaving it empty or null is to not change the duration.", + "title": "Duration", + "type": "number" + }, + "duration_unit": { + "default": null, + "description": "The updated unit of the task duration.", + "enum": [ + "minutes", + "hours", + "days" + ], + "title": "Duration Unit", + "type": "string" + }, + "priority": { + "default": null, + "description": "The updated priority of the sub_task.", + "enum": [ + "high", + "medium", + "low" + ], + "title": "Priority", + "type": "string" + }, + "is_completed": { + "default": null, + "description": "The updated completion status of the sub_task. True if completed, False otherwise.", + "title": "Is Completed", + "type": "boolean" + }, + "order_index": { + "default": null, + "description": "The updated index at which this sub_task should be inserted in the current list of sub_tasks.", + "title": "Order Index", + "type": "integer" + }, + "sub_task_id": { + "description": "The ID of the sub_task.", + "title": "Sub Task Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "sub_task_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__delete_sub_task", + "description": "Delete a sub_task.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "sub_task_id": { + "description": "The ID of the sub_task to delete.", + "title": "Sub Task Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "sub_task_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__create_label", + "description": "Create a new label.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "name": { + "description": "The name of the label.", + "minLength": 1, + "title": "Name", + "type": "string" + }, + "color": { + "default": "charcoal", + "description": "The color of the label.", + "enum": [ + "charcoal", + "red", + "blue", + "green", + "orange", + "yellow" + ], + "title": "Color", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "name", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__search_labels", + "description": "Search your or task labels.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "default": "", + "description": "The search query string.", + "title": "Query", + "type": "string" + }, + "task_id": { + "default": null, + "description": "The ID of the task to filter by.", + "title": "Task Id", + "type": "integer" + }, + "color": { + "default": null, + "description": "The color of the label to filter by.", + "enum": [ + "charcoal", + "red", + "blue", + "green", + "orange", + "yellow" + ], + "title": "Color", + "type": "string" + }, + "task_attached": { + "default": true, + "description": "If true, and task_id is passed, labels attached to the task will be returned. If false, and task_id is passed, your labels that are not already attached to the task will be returned. It will be ignored if task_id is not passed.", + "title": "Task Attached", + "type": "boolean" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 9223372036854775807, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__show_label", + "description": "Show label details based on its ID.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "label_id": { + "description": "The ID of the label.", + "title": "Label Id", + "type": "integer" + } + }, + "required": [ + "label_id" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__update_label", + "description": "Update label information.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "name": { + "default": null, + "description": "The updated name of the label.", + "minLength": 1, + "title": "Name", + "type": "string" + }, + "color": { + "default": null, + "description": "The updated color of the label.", + "enum": [ + "charcoal", + "red", + "blue", + "green", + "orange", + "yellow" + ], + "title": "Color", + "type": "string" + }, + "label_id": { + "description": "The ID of the label.", + "title": "Label Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "label_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__delete_label", + "description": "Delete a label.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "label_id": { + "description": "The ID of the label to delete.", + "title": "Label Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "label_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__add_label_to_task", + "description": "Add a label to a task.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "label_id": { + "description": "The ID of the label to add to the task.", + "title": "Label Id", + "type": "integer" + }, + "task_id": { + "description": "The ID of the task.", + "title": "Task Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "label_id", + "task_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__remove_label_from_task", + "description": "Remove a label from a task.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "task_id": { + "description": "The ID of the task.", + "title": "Task Id", + "type": "integer" + }, + "label_id": { + "description": "The ID of the label to remove from the task.", + "title": "Label Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "task_id", + "label_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__post_task_comment", + "description": "Post a comment on a task, optionally with a file attachment.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "content": { + "description": "The content of the comment.", + "title": "Content", + "type": "string" + }, + "attachment_file_paths": { + "default": null, + "description": "Paths to files to be attached in this comment.", + "items": { + "type": "string" + }, + "title": "Attachment File Paths", + "type": "array" + }, + "file_system_access_token": { + "default": null, + "description": "Access token obtained from file_system app login. Only needed if comment has a file attachment.", + "title": "File System Access Token", + "type": "string" + }, + "task_id": { + "description": "The ID of the task.", + "title": "Task Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "content", + "task_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__show_task_comments", + "description": "Get a list of task comments.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "task_id": { + "description": "The ID of the task.", + "title": "Task Id", + "type": "integer" + }, + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "task_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__show_task_comment", + "description": "Show a task comment.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "task_comment_id": { + "description": "The ID of the task comment.", + "title": "Task Comment Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "task_comment_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__update_task_comment", + "description": "Update a task comment.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "content": { + "default": null, + "description": "The updated content of the comment.", + "title": "Content", + "type": "string" + }, + "task_comment_id": { + "description": "The ID of the task comment.", + "title": "Task Comment Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "task_comment_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__delete_task_comment", + "description": "Delete a task comment.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "task_comment_id": { + "description": "The ID of the comment to delete.", + "title": "Task Comment Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "task_comment_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__upload_attachment", + "description": "Upload an attachment to a task comment.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "upload_from_file_path": { + "description": "The file path in file system app to upload as an attachment.", + "title": "Upload From File Path", + "type": "string" + }, + "overwrite": { + "default": false, + "description": "Whether to overwrite the attachment if one with the same name already exists.", + "title": "Overwrite", + "type": "boolean" + }, + "file_system_access_token": { + "description": "Access token obtained from file_system app login.", + "title": "File System Access Token", + "type": "string" + }, + "task_comment_id": { + "description": "The ID of the task comment to attach the file to.", + "title": "Task Comment Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "upload_from_file_path", + "file_system_access_token", + "task_comment_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__download_attachment", + "description": "Download the attachment of a task comment.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "task_comment_id": { + "description": "The ID of the comment to download the attachment from.", + "title": "Task Comment Id", + "type": "integer" + }, + "attachment_file_name": { + "description": "Name of the file attached to the task comment.", + "title": "Attachment File Name", + "type": "string" + }, + "download_to_file_path": { + "default": null, + "description": "The file path to download the file attachment to in file system app. Path can be absolute, starting with '/', or relative to the user's home directory, starting with '~/'. If not passed, it will be saved in your ~/downloads directory.", + "title": "Download To File Path", + "type": "string" + }, + "overwrite": { + "default": false, + "description": "Whether to overwrite the file if it already exists.", + "title": "Overwrite", + "type": "boolean" + }, + "file_system_access_token": { + "description": "Access token obtained from file_system app login.", + "title": "File System Access Token", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "task_comment_id", + "attachment_file_name", + "file_system_access_token", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__delete_attachment", + "description": "Delete an attachment of a task comment.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "task_comment_id": { + "description": "The ID of the task comment to delete the attachment from.", + "title": "Task Comment Id", + "type": "integer" + }, + "attachment_file_name": { + "description": "Name of the file attached to the task comment.", + "title": "Attachment File Name", + "type": "string" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "task_comment_id", + "attachment_file_name", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__show_notifications", + "description": "Get a list of your notifications.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "page_index": { + "default": 0, + "description": "The index of the page to return.", + "minimum": 0, + "title": "Page Index", + "type": "integer" + }, + "page_limit": { + "default": 5, + "description": "The maximum number of results to return per page.", + "maximum": 20, + "minimum": 1, + "title": "Page Limit", + "type": "integer" + }, + "read": { + "default": null, + "description": "Filter notifications by read status. Will skip filtering if not passed.", + "title": "Read", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__delete_notifications", + "description": "Delete all of your notifications.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__mark_notifications", + "description": "Mark all notifications as read or unread.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "read": { + "description": "Read status of the notification.", + "title": "Read", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "read", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__show_notifications_count", + "description": "Get the count of your notifications.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "read": { + "default": null, + "description": "Count notifications by read status. Will count all if not passed.", + "title": "Read", + "type": "boolean" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__delete_notification", + "description": "Delete a notification.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "notification_id": { + "description": "The ID of the notification to delete.", + "title": "Notification Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "notification_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + }, + { + "name": "todoist__mark_notification", + "description": "Mark a notification as read or unread.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "read": { + "description": "Mark the notification as read or unread.", + "title": "Read", + "type": "boolean" + }, + "notification_id": { + "description": "ID of the notification to mark.", + "title": "Notification Id", + "type": "integer" + }, + "access_token": { + "description": "Access token obtained from todoist app login.", + "title": "Access Token", + "type": "string" + } + }, + "required": [ + "read", + "notification_id", + "access_token" + ], + "title": "DynamicModel", + "type": "object" + } + } + ], + "task_id": "f3f60f0_3" +} diff --git a/labs/AgentStream/exgentic/tests/benchmarks/recordings/appworld/trajectory.jsonl b/labs/AgentStream/exgentic/tests/benchmarks/recordings/appworld/trajectory.jsonl new file mode 100644 index 00000000..03a20c7f --- /dev/null +++ b/labs/AgentStream/exgentic/tests/benchmarks/recordings/appworld/trajectory.jsonl @@ -0,0 +1,63 @@ +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 0, "observation": {"result": null, "invoking_actions": []}, "initial": true, "agent_cost": 0, "benchmark_cost": 0} +{"event": "action", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 1, "action": {"name": "supervisor__show_account_passwords", "arguments": {}, "id": "fe008c02-888f-478e-9aa3-0d29f5da7dab", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.49676000000000003, "benchmark_cost": 0} +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 1, "observation": {"result": [{"account_name": "amazon", "password": "0i}WIpn"}, {"account_name": "file_system", "password": "Pqt[Yzd"}, {"account_name": "gmail", "password": "0L-CG0w"}, {"account_name": "phone", "password": "iGT$ymP"}, {"account_name": "simple_note", "password": "-Bw+D)w"}, {"account_name": "splitwise", "password": "W$eVtvV"}, {"account_name": "spotify", "password": "Vum}&9h"}, {"account_name": "todoist", "password": "1UAMVeE"}, {"account_name": "venmo", "password": "D7}ed$N"}], "invoking_actions": [{"name": "supervisor__show_account_passwords", "arguments": {}, "id": "fe008c02-888f-478e-9aa3-0d29f5da7dab", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.49676000000000003, "benchmark_cost": 0} +{"event": "action", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 2, "action": {"name": "supervisor__show_profile", "arguments": {}, "id": "43095531-617a-45fd-9f94-50d9e03b9129", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.994195, "benchmark_cost": 0} +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 2, "observation": {"result": {"first_name": "Caitlin", "last_name": "Burch", "email": "caiburc@gmail.com", "phone_number": "5896230193", "birthday": "1962-09-05", "sex": "female"}, "invoking_actions": [{"name": "supervisor__show_profile", "arguments": {}, "id": "43095531-617a-45fd-9f94-50d9e03b9129", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.994195, "benchmark_cost": 0} +{"event": "action", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 3, "action": {"name": "spotify__login", "arguments": {"username": "caiburc@gmail.com", "password": "Vum}&9h"}, "id": "389bf808-3a0c-4299-aaa1-960dd77a134d", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 1.493355, "benchmark_cost": 0} +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 3, "observation": {"result": {"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzcG90aWZ5K2NhaWJ1cmNAZ21haWwuY29tIiwiZXhwIjoxNjg0NDEyMDk4fQ.7pIZgwN8dQjGbediu4d8La107fF_vrxRODqJOls1Ek0", "token_type": "Bearer"}, "invoking_actions": [{"name": "spotify__login", "arguments": {}, "id": "389bf808-3a0c-4299-aaa1-960dd77a134d", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 1.493355, "benchmark_cost": 0} +{"event": "action", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 4, "action": {"name": "spotify__show_song_library", "arguments": {"query": "", "page_index": 0, "page_limit": 100, "sort_by": null, "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzcG90aWZ5K2NhaWJ1cmNAZ21haWwuY29tIiwiZXhwIjoxNjg0NDEyMDk4fQ.7pIZgwN8dQjGbediu4d8La107fF_vrxRODqJOls1Ek0"}, "id": "7af5dfb8-3919-43e9-b7df-9b1f0d893424", "validation": {"valid": false, "name_valid": true, "args_valid": false, "error": "Field 'arguments.page_limit': Input should be less than or equal to 20 (received 100 of type int).Field 'arguments.sort_by': Input should be a valid string (received None of type NoneType).", "details": {"errors": [{"type": "less_than_equal", "loc": ["arguments", "page_limit"], "msg": "Input should be less than or equal to 20", "input": 100, "ctx": {"le": 20}, "url": "https://errors.pydantic.dev/2.12/v/less_than_equal"}, {"type": "string_type", "loc": ["arguments", "sort_by"], "msg": "Input should be a valid string", "input": null, "url": "https://errors.pydantic.dev/2.12/v/string_type"}]}}}, "initial": false, "agent_cost": 2.01317, "benchmark_cost": 0} +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 4, "observation": {"result": "Error: Validation error. Reason: \npage_limit: Input should be less than or equal to 20", "invoking_actions": [{"name": "spotify__show_song_library", "arguments": {}, "id": "7af5dfb8-3919-43e9-b7df-9b1f0d893424", "validation": {"valid": false, "name_valid": true, "args_valid": false, "error": "Field 'arguments.page_limit': Input should be less than or equal to 20 (received 100 of type int).Field 'arguments.sort_by': Input should be a valid string (received None of type NoneType).", "details": {"errors": [{"type": "less_than_equal", "loc": ["arguments", "page_limit"], "msg": "Input should be less than or equal to 20", "input": 100, "ctx": {"le": 20}, "url": "https://errors.pydantic.dev/2.12/v/less_than_equal"}, {"type": "string_type", "loc": ["arguments", "sort_by"], "msg": "Input should be a valid string", "input": null, "url": "https://errors.pydantic.dev/2.12/v/string_type"}]}}}]}, "initial": false, "agent_cost": 2.01317, "benchmark_cost": 0} +{"event": "action", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 5, "action": {"name": "spotify__show_album_library", "arguments": {"query": "", "page_index": 0, "page_limit": 100, "sort_by": null, "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzcG90aWZ5K2NhaWJ1cmNAZ21haWwuY29tIiwiZXhwIjoxNjg0NDEyMDk4fQ.7pIZgwN8dQjGbediu4d8La107fF_vrxRODqJOls1Ek0"}, "id": "5697268d-2243-4289-8f4a-dfbcf7c47a32", "validation": {"valid": false, "name_valid": true, "args_valid": false, "error": "Field 'arguments.page_limit': Input should be less than or equal to 20 (received 100 of type int).Field 'arguments.sort_by': Input should be a valid string (received None of type NoneType).", "details": {"errors": [{"type": "less_than_equal", "loc": ["arguments", "page_limit"], "msg": "Input should be less than or equal to 20", "input": 100, "ctx": {"le": 20}, "url": "https://errors.pydantic.dev/2.12/v/less_than_equal"}, {"type": "string_type", "loc": ["arguments", "sort_by"], "msg": "Input should be a valid string", "input": null, "url": "https://errors.pydantic.dev/2.12/v/string_type"}]}}}, "initial": false, "agent_cost": 2.01317, "benchmark_cost": 0} +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 5, "observation": {"result": "Error: Validation error. Reason: \npage_limit: Input should be less than or equal to 20", "invoking_actions": [{"name": "spotify__show_album_library", "arguments": {}, "id": "5697268d-2243-4289-8f4a-dfbcf7c47a32", "validation": {"valid": false, "name_valid": true, "args_valid": false, "error": "Field 'arguments.page_limit': Input should be less than or equal to 20 (received 100 of type int).Field 'arguments.sort_by': Input should be a valid string (received None of type NoneType).", "details": {"errors": [{"type": "less_than_equal", "loc": ["arguments", "page_limit"], "msg": "Input should be less than or equal to 20", "input": 100, "ctx": {"le": 20}, "url": "https://errors.pydantic.dev/2.12/v/less_than_equal"}, {"type": "string_type", "loc": ["arguments", "sort_by"], "msg": "Input should be a valid string", "input": null, "url": "https://errors.pydantic.dev/2.12/v/string_type"}]}}}]}, "initial": false, "agent_cost": 2.01317, "benchmark_cost": 0} +{"event": "action", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 6, "action": {"name": "spotify__show_liked_songs", "arguments": {"page_index": 0, "page_limit": 100, "sort_by": "-liked_at", "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzcG90aWZ5K2NhaWJ1cmNAZ21haWwuY29tIiwiZXhwIjoxNjg0NDEyMDk4fQ.7pIZgwN8dQjGbediu4d8La107fF_vrxRODqJOls1Ek0"}, "id": "a11ca50b-b723-499c-80f2-e5d5f75d3441", "validation": {"valid": false, "name_valid": true, "args_valid": false, "error": "Field 'arguments.page_limit': Input should be less than or equal to 20 (received 100 of type int).", "details": {"errors": [{"type": "less_than_equal", "loc": ["arguments", "page_limit"], "msg": "Input should be less than or equal to 20", "input": 100, "ctx": {"le": 20}, "url": "https://errors.pydantic.dev/2.12/v/less_than_equal"}]}}}, "initial": false, "agent_cost": 2.01317, "benchmark_cost": 0} +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 6, "observation": {"result": "Error: Validation error. Reason: \npage_limit: Input should be less than or equal to 20", "invoking_actions": [{"name": "spotify__show_liked_songs", "arguments": {}, "id": "a11ca50b-b723-499c-80f2-e5d5f75d3441", "validation": {"valid": false, "name_valid": true, "args_valid": false, "error": "Field 'arguments.page_limit': Input should be less than or equal to 20 (received 100 of type int).", "details": {"errors": [{"type": "less_than_equal", "loc": ["arguments", "page_limit"], "msg": "Input should be less than or equal to 20", "input": 100, "ctx": {"le": 20}, "url": "https://errors.pydantic.dev/2.12/v/less_than_equal"}]}}}]}, "initial": false, "agent_cost": 2.01317, "benchmark_cost": 0} +{"event": "action", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 7, "action": {"name": "spotify__show_liked_albums", "arguments": {"page_index": 0, "page_limit": 100, "sort_by": "-liked_at", "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzcG90aWZ5K2NhaWJ1cmNAZ21haWwuY29tIiwiZXhwIjoxNjg0NDEyMDk4fQ.7pIZgwN8dQjGbediu4d8La107fF_vrxRODqJOls1Ek0"}, "id": "2b16275c-0e9a-44d9-9cb5-f7996c3df003", "validation": {"valid": false, "name_valid": true, "args_valid": false, "error": "Field 'arguments.page_limit': Input should be less than or equal to 20 (received 100 of type int).", "details": {"errors": [{"type": "less_than_equal", "loc": ["arguments", "page_limit"], "msg": "Input should be less than or equal to 20", "input": 100, "ctx": {"le": 20}, "url": "https://errors.pydantic.dev/2.12/v/less_than_equal"}]}}}, "initial": false, "agent_cost": 2.01317, "benchmark_cost": 0} +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 7, "observation": {"result": "Error: Validation error. Reason: \npage_limit: Input should be less than or equal to 20", "invoking_actions": [{"name": "spotify__show_liked_albums", "arguments": {}, "id": "2b16275c-0e9a-44d9-9cb5-f7996c3df003", "validation": {"valid": false, "name_valid": true, "args_valid": false, "error": "Field 'arguments.page_limit': Input should be less than or equal to 20 (received 100 of type int).", "details": {"errors": [{"type": "less_than_equal", "loc": ["arguments", "page_limit"], "msg": "Input should be less than or equal to 20", "input": 100, "ctx": {"le": 20}, "url": "https://errors.pydantic.dev/2.12/v/less_than_equal"}]}}}]}, "initial": false, "agent_cost": 2.01317, "benchmark_cost": 0} +{"event": "action", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 8, "action": {"name": "spotify__show_song_library", "arguments": {"query": "", "page_index": 0, "page_limit": 20, "sort_by": null, "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzcG90aWZ5K2NhaWJ1cmNAZ21haWwuY29tIiwiZXhwIjoxNjg0NDEyMDk4fQ.7pIZgwN8dQjGbediu4d8La107fF_vrxRODqJOls1Ek0"}, "id": "7cc41938-6441-42d7-a5d3-87d917193d62", "validation": {"valid": false, "name_valid": true, "args_valid": false, "error": "Field 'arguments.sort_by': Input should be a valid string (received None of type NoneType).", "details": {"errors": [{"type": "string_type", "loc": ["arguments", "sort_by"], "msg": "Input should be a valid string", "input": null, "url": "https://errors.pydantic.dev/2.12/v/string_type"}]}}}, "initial": false, "agent_cost": 2.5384650000000004, "benchmark_cost": 0} +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 8, "observation": {"result": [{"song_id": 284, "title": "Under the Weight of Regret", "album_id": null, "album_title": null, "duration": 203, "artists": [{"id": 30, "name": "Marcus Lane"}], "added_at": "2023-05-13T04:58:42"}, {"song_id": 217, "title": "Torn Between Two Worlds", "album_id": null, "album_title": null, "duration": 224, "artists": [{"id": 21, "name": "Oceanic Odyssey"}], "added_at": "2023-03-31T06:58:52"}, {"song_id": 4, "title": "The Puzzle of My Heart", "album_id": 1, "album_title": "Echo Chamber Chronicles", "duration": 181, "artists": [{"id": 3, "name": "Jasper Skye"}, {"id": 26, "name": "Isabella Cruz"}, {"id": 6, "name": "Seraphina Dawn"}], "added_at": "2023-03-24T02:35:55"}, {"song_id": 272, "title": "Torn Apart", "album_id": null, "album_title": null, "duration": 260, "artists": [{"id": 28, "name": "Evelyn Rose"}], "added_at": "2023-03-21T13:29:32"}, {"song_id": 101, "title": "Morning Haze", "album_id": null, "album_title": null, "duration": 181, "artists": [{"id": 4, "name": "Marigold Muse"}], "added_at": "2023-03-21T08:57:03"}, {"song_id": 17, "title": "Dancing Through the Veil of Dreams", "album_id": 4, "album_title": "Neon Echoes", "duration": 238, "artists": [{"id": 36, "name": "Noah Bennett"}], "added_at": "2023-03-12T05:06:59"}, {"song_id": 318, "title": "Mystic Voyage to Avalon", "album_id": null, "album_title": null, "duration": 203, "artists": [{"id": 35, "name": "Nova Harmony"}], "added_at": "2022-11-23T22:09:30"}, {"song_id": 94, "title": "Bridges Burned and Rivers Crossed", "album_id": null, "album_title": null, "duration": 250, "artists": [{"id": 3, "name": "Jasper Skye"}], "added_at": "2022-11-08T12:05:23"}, {"song_id": 146, "title": "Drifting Through Eternal Skies", "album_id": null, "album_title": null, "duration": 294, "artists": [{"id": 11, "name": "Eliana Harper"}], "added_at": "2022-10-13T18:24:49"}, {"song_id": 194, "title": "Crumbling Castles in the Sand", "album_id": null, "album_title": null, "duration": 259, "artists": [{"id": 18, "name": "Astrid Nightshade"}], "added_at": "2022-10-04T14:09:22"}, {"song_id": 128, "title": "Silent Sorrow", "album_id": null, "album_title": null, "duration": 297, "artists": [{"id": 8, "name": "Aria Sterling"}], "added_at": "2022-08-26T16:37:30"}, {"song_id": 62, "title": "Crimson Sunset Sonata", "album_id": 12, "album_title": "Astral Journey", "duration": 255, "artists": [{"id": 9, "name": "Zoey James"}], "added_at": "2022-08-18T05:42:01"}, {"song_id": 143, "title": "Chasing Ghosts", "album_id": null, "album_title": null, "duration": 199, "artists": [{"id": 10, "name": "Zoe Mitchell"}], "added_at": "2022-08-06T13:14:32"}, {"song_id": 34, "title": "Under the Gaze of a Watchful Moon", "album_id": 7, "album_title": "Vibrant Visions", "duration": 196, "artists": [{"id": 11, "name": "Eliana Harper"}], "added_at": "2022-05-24T09:05:46"}], "invoking_actions": [{"name": "spotify__show_song_library", "arguments": {}, "id": "7cc41938-6441-42d7-a5d3-87d917193d62", "validation": {"valid": false, "name_valid": true, "args_valid": false, "error": "Field 'arguments.sort_by': Input should be a valid string (received None of type NoneType).", "details": {"errors": [{"type": "string_type", "loc": ["arguments", "sort_by"], "msg": "Input should be a valid string", "input": null, "url": "https://errors.pydantic.dev/2.12/v/string_type"}]}}}]}, "initial": false, "agent_cost": 2.5384650000000004, "benchmark_cost": 0} +{"event": "action", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 9, "action": {"name": "spotify__show_album_library", "arguments": {"query": "", "page_index": 0, "page_limit": 20, "sort_by": null, "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzcG90aWZ5K2NhaWJ1cmNAZ21haWwuY29tIiwiZXhwIjoxNjg0NDEyMDk4fQ.7pIZgwN8dQjGbediu4d8La107fF_vrxRODqJOls1Ek0"}, "id": "9b45173b-7f10-4328-af49-dadf3551340c", "validation": {"valid": false, "name_valid": true, "args_valid": false, "error": "Field 'arguments.sort_by': Input should be a valid string (received None of type NoneType).", "details": {"errors": [{"type": "string_type", "loc": ["arguments", "sort_by"], "msg": "Input should be a valid string", "input": null, "url": "https://errors.pydantic.dev/2.12/v/string_type"}]}}}, "initial": false, "agent_cost": 2.5384650000000004, "benchmark_cost": 0} +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 9, "observation": {"result": [{"album_id": 8, "title": "Velvet Underground", "genre": "jazz", "artists": [{"id": 8, "name": "Aria Sterling"}], "rating": 3.6, "like_count": 38, "review_count": 17, "release_date": "2021-06-10T10:41:47", "song_ids": [37, 38, 39, 40, 41, 42, 43], "added_at": "2023-04-29T10:24:51"}, {"album_id": 3, "title": "Nocturnal Melodies", "genre": "R&B", "artists": [{"id": 16, "name": "Apollo Serenade"}, {"id": 2, "name": "Phoenix Rivers"}], "rating": 3.5, "like_count": 34, "review_count": 24, "release_date": "2022-11-08T06:55:52", "song_ids": [11, 12, 13, 14, 15], "added_at": "2023-04-23T10:54:43"}, {"album_id": 5, "title": "Velvet Shadows", "genre": "R&B", "artists": [{"id": 32, "name": "Lucas Grey"}, {"id": 25, "name": "Velvet Echo"}], "rating": 3.5, "like_count": 38, "review_count": 22, "release_date": "2022-07-15T17:46:36", "song_ids": [23, 24, 25, 26, 27, 28], "added_at": "2023-04-16T04:55:09"}, {"album_id": 4, "title": "Neon Echoes", "genre": "indie", "artists": [{"id": 36, "name": "Noah Bennett"}], "rating": 3.4, "like_count": 33, "review_count": 20, "release_date": "2021-03-23T13:53:26", "song_ids": [16, 17, 18, 19, 20, 21, 22], "added_at": "2023-03-07T07:57:08"}, {"album_id": 11, "title": "Synaptic Serenity", "genre": "EDM", "artists": [{"id": 5, "name": "Ava Morgan"}], "rating": 3.9, "like_count": 46, "review_count": 18, "release_date": "2022-12-11T06:22:44", "song_ids": [54, 55, 56, 57], "added_at": "2023-02-05T14:12:20"}, {"album_id": 18, "title": "Echoes of Eternity", "genre": "classical", "artists": [{"id": 33, "name": "Felix Blackwood"}], "rating": 3.7, "like_count": 37, "review_count": 21, "release_date": "2022-07-06T10:24:33", "song_ids": [80, 81, 82], "added_at": "2022-12-28T05:26:11"}, {"album_id": 9, "title": "Mystical Crescendo", "genre": "jazz", "artists": [{"id": 8, "name": "Aria Sterling"}], "rating": 3.4, "like_count": 36, "review_count": 12, "release_date": "2022-09-27T00:38:12", "song_ids": [44, 45, 46], "added_at": "2022-09-29T15:27:31"}, {"album_id": 14, "title": "Midnight Serenade", "genre": "rock", "artists": [{"id": 34, "name": "Lily Moon"}, {"id": 9, "name": "Zoey James"}], "rating": 3.4, "like_count": 44, "review_count": 14, "release_date": "2022-02-21T17:34:33", "song_ids": [67, 68, 69], "added_at": "2022-07-17T00:52:57"}], "invoking_actions": [{"name": "spotify__show_album_library", "arguments": {}, "id": "9b45173b-7f10-4328-af49-dadf3551340c", "validation": {"valid": false, "name_valid": true, "args_valid": false, "error": "Field 'arguments.sort_by': Input should be a valid string (received None of type NoneType).", "details": {"errors": [{"type": "string_type", "loc": ["arguments", "sort_by"], "msg": "Input should be a valid string", "input": null, "url": "https://errors.pydantic.dev/2.12/v/string_type"}]}}}]}, "initial": false, "agent_cost": 2.5384650000000004, "benchmark_cost": 0} +{"event": "action", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 10, "action": {"name": "spotify__show_liked_songs", "arguments": {"page_index": 0, "page_limit": 20, "sort_by": "-liked_at", "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzcG90aWZ5K2NhaWJ1cmNAZ21haWwuY29tIiwiZXhwIjoxNjg0NDEyMDk4fQ.7pIZgwN8dQjGbediu4d8La107fF_vrxRODqJOls1Ek0"}, "id": "13147fce-e18d-4194-8475-03ed0b008d0d", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 2.5384650000000004, "benchmark_cost": 0} +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 10, "observation": {"result": [{"song_id": 279, "title": "The Silence After the Storm", "album_id": null, "album_title": null, "duration": 193, "artists": [{"id": 29, "name": "Lucas Diaz"}], "liked_at": "2023-05-05T07:06:05"}, {"song_id": 24, "title": "The Unseen Scars of Love", "album_id": 5, "album_title": "Velvet Shadows", "duration": 281, "artists": [{"id": 32, "name": "Lucas Grey"}, {"id": 25, "name": "Velvet Echo"}], "liked_at": "2023-04-11T10:42:07"}, {"song_id": 86, "title": "Shattered", "album_id": null, "album_title": null, "duration": 250, "artists": [{"id": 1, "name": "Olivia Roberts"}], "liked_at": "2023-03-06T05:53:51"}, {"song_id": 322, "title": "Lost in the Labyrinth of Love", "album_id": null, "album_title": null, "duration": 281, "artists": [{"id": 36, "name": "Noah Bennett"}], "liked_at": "2023-02-21T20:53:03"}, {"song_id": 57, "title": "Silver Lining", "album_id": 11, "album_title": "Synaptic Serenity", "duration": 287, "artists": [{"id": 5, "name": "Ava Morgan"}], "liked_at": "2023-01-10T17:58:43"}, {"song_id": 154, "title": "The Ghosts of Our Past", "album_id": null, "album_title": null, "duration": 204, "artists": [{"id": 12, "name": "Mia Sullivan"}], "liked_at": "2022-12-02T06:50:57"}, {"song_id": 202, "title": "Summer's End", "album_id": null, "album_title": null, "duration": 277, "artists": [{"id": 19, "name": "Ethan Wallace"}], "liked_at": "2022-11-22T03:25:34"}, {"song_id": 285, "title": "Wading Through the Ashes of Love", "album_id": null, "album_title": null, "duration": 195, "artists": [{"id": 30, "name": "Marcus Lane"}], "liked_at": "2022-10-10T19:08:42"}, {"song_id": 203, "title": "Lost in the Echoes of Time", "album_id": null, "album_title": null, "duration": 245, "artists": [{"id": 19, "name": "Ethan Wallace"}], "liked_at": "2022-10-06T16:53:59"}, {"song_id": 277, "title": "Hidden Scars", "album_id": null, "album_title": null, "duration": 297, "artists": [{"id": 29, "name": "Lucas Diaz"}], "liked_at": "2022-09-08T21:53:57"}, {"song_id": 195, "title": "Rhapsody of the Ocean's Embrace", "album_id": null, "album_title": null, "duration": 298, "artists": [{"id": 18, "name": "Astrid Nightshade"}], "liked_at": "2022-08-01T23:42:15"}, {"song_id": 106, "title": "The Irony of a Perfect Moment", "album_id": null, "album_title": null, "duration": 185, "artists": [{"id": 5, "name": "Ava Morgan"}], "liked_at": "2022-06-01T05:40:47"}, {"song_id": 17, "title": "Dancing Through the Veil of Dreams", "album_id": 4, "album_title": "Neon Echoes", "duration": 238, "artists": [{"id": 36, "name": "Noah Bennett"}], "liked_at": "2022-05-28T22:29:45"}], "invoking_actions": [{"name": "spotify__show_liked_songs", "arguments": {}, "id": "13147fce-e18d-4194-8475-03ed0b008d0d", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 2.5384650000000004, "benchmark_cost": 0} +{"event": "action", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 11, "action": {"name": "spotify__show_liked_albums", "arguments": {"page_index": 0, "page_limit": 20, "sort_by": "-liked_at", "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzcG90aWZ5K2NhaWJ1cmNAZ21haWwuY29tIiwiZXhwIjoxNjg0NDEyMDk4fQ.7pIZgwN8dQjGbediu4d8La107fF_vrxRODqJOls1Ek0"}, "id": "086dc0d6-0bcf-4bba-9674-c078f462287b", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 2.5384650000000004, "benchmark_cost": 0} +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 11, "observation": {"result": [{"album_id": 17, "title": "Enchanted Melodies", "genre": "classical", "artists": [{"id": 12, "name": "Mia Sullivan"}, {"id": 27, "name": "Carter Knight"}], "rating": 3.6, "like_count": 40, "review_count": 17, "release_date": "2020-10-15T13:57:09", "song_ids": [77, 78, 79], "liked_at": "2023-02-08T15:57:56"}, {"album_id": 15, "title": "Whispers in the Wind", "genre": "pop", "artists": [{"id": 29, "name": "Lucas Diaz"}, {"id": 7, "name": "Orion Steele"}], "rating": 3.8, "like_count": 34, "review_count": 17, "release_date": "2022-08-17T08:43:21", "song_ids": [70, 71, 72, 73], "liked_at": "2023-01-11T11:28:09"}, {"album_id": 2, "title": "Celestial Harmonies", "genre": "R&B", "artists": [{"id": 32, "name": "Lucas Grey"}], "rating": 3.6, "like_count": 44, "review_count": 17, "release_date": "2022-05-21T21:55:36", "song_ids": [8, 9, 10], "liked_at": "2022-12-29T00:01:55"}, {"album_id": 6, "title": "Ethereal Rhapsody", "genre": "indie", "artists": [{"id": 36, "name": "Noah Bennett"}, {"id": 19, "name": "Ethan Wallace"}, {"id": 35, "name": "Nova Harmony"}], "rating": 3.9, "like_count": 37, "review_count": 18, "release_date": "2021-04-02T04:32:45", "song_ids": [29, 30, 31, 32], "liked_at": "2022-12-24T14:10:12"}, {"album_id": 10, "title": "Dreamscape Delights", "genre": "jazz", "artists": [{"id": 8, "name": "Aria Sterling"}], "rating": 3.0, "like_count": 30, "review_count": 10, "release_date": "2022-11-11T09:54:16", "song_ids": [47, 48, 49, 50, 51, 52, 53], "liked_at": "2022-12-12T15:31:55"}, {"album_id": 7, "title": "Vibrant Visions", "genre": "hip-hop", "artists": [{"id": 11, "name": "Eliana Harper"}], "rating": 3.4, "like_count": 44, "review_count": 20, "release_date": "2021-02-05T04:10:20", "song_ids": [33, 34, 35, 36], "liked_at": "2022-11-22T14:11:18"}, {"album_id": 1, "title": "Echo Chamber Chronicles", "genre": "EDM", "artists": [{"id": 3, "name": "Jasper Skye"}, {"id": 26, "name": "Isabella Cruz"}, {"id": 6, "name": "Seraphina Dawn"}], "rating": 3.2, "like_count": 34, "review_count": 12, "release_date": "2022-10-29T01:54:53", "song_ids": [1, 2, 3, 4, 5, 6, 7], "liked_at": "2022-10-28T09:08:14"}, {"album_id": 16, "title": "Electric Dreamscape", "genre": "rock", "artists": [{"id": 34, "name": "Lily Moon"}, {"id": 9, "name": "Zoey James"}], "rating": 3.8, "like_count": 32, "review_count": 16, "release_date": "2022-08-02T08:03:50", "song_ids": [74, 75, 76], "liked_at": "2022-10-12T04:22:15"}, {"album_id": 13, "title": "Starlight Serenades", "genre": "EDM", "artists": [{"id": 31, "name": "Hazel Winter"}, {"id": 5, "name": "Ava Morgan"}], "rating": 3.3, "like_count": 39, "review_count": 17, "release_date": "2021-03-02T14:08:32", "song_ids": [63, 64, 65, 66], "liked_at": "2022-09-12T18:51:25"}, {"album_id": 4, "title": "Neon Echoes", "genre": "indie", "artists": [{"id": 36, "name": "Noah Bennett"}], "rating": 3.4, "like_count": 33, "review_count": 20, "release_date": "2021-03-23T13:53:26", "song_ids": [16, 17, 18, 19, 20, 21, 22], "liked_at": "2022-08-25T20:36:43"}, {"album_id": 11, "title": "Synaptic Serenity", "genre": "EDM", "artists": [{"id": 5, "name": "Ava Morgan"}], "rating": 3.9, "like_count": 46, "review_count": 18, "release_date": "2022-12-11T06:22:44", "song_ids": [54, 55, 56, 57], "liked_at": "2022-06-24T14:53:19"}, {"album_id": 12, "title": "Astral Journey", "genre": "rock", "artists": [{"id": 9, "name": "Zoey James"}], "rating": 3.7, "like_count": 43, "review_count": 16, "release_date": "2021-10-02T22:00:08", "song_ids": [58, 59, 60, 61, 62], "liked_at": "2022-05-26T18:37:41"}], "invoking_actions": [{"name": "spotify__show_liked_albums", "arguments": {}, "id": "086dc0d6-0bcf-4bba-9674-c078f462287b", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 2.5384650000000004, "benchmark_cost": 0} +{"event": "action", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 12, "action": {"name": "spotify__like_song", "arguments": {"song_id": 284, "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzcG90aWZ5K2NhaWJ1cmNAZ21haWwuY29tIiwiZXhwIjoxNjg0NDEyMDk4fQ.7pIZgwN8dQjGbediu4d8La107fF_vrxRODqJOls1Ek0"}, "id": "6378d779-84dd-4661-8c24-415d938a6e9e", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 12, "observation": {"result": {"message": "Song liked."}, "invoking_actions": [{"name": "spotify__like_song", "arguments": {}, "id": "6378d779-84dd-4661-8c24-415d938a6e9e", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "action", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 13, "action": {"name": "spotify__like_song", "arguments": {"song_id": 217, "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzcG90aWZ5K2NhaWJ1cmNAZ21haWwuY29tIiwiZXhwIjoxNjg0NDEyMDk4fQ.7pIZgwN8dQjGbediu4d8La107fF_vrxRODqJOls1Ek0"}, "id": "cef80a8b-4f00-4da2-8387-c82fb4e8001e", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 13, "observation": {"result": {"message": "Song liked."}, "invoking_actions": [{"name": "spotify__like_song", "arguments": {}, "id": "cef80a8b-4f00-4da2-8387-c82fb4e8001e", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "action", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 14, "action": {"name": "spotify__like_song", "arguments": {"song_id": 4, "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzcG90aWZ5K2NhaWJ1cmNAZ21haWwuY29tIiwiZXhwIjoxNjg0NDEyMDk4fQ.7pIZgwN8dQjGbediu4d8La107fF_vrxRODqJOls1Ek0"}, "id": "f7882044-96dc-4bb1-9db3-9c9f2b2e2d87", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 14, "observation": {"result": {"message": "Song liked."}, "invoking_actions": [{"name": "spotify__like_song", "arguments": {}, "id": "f7882044-96dc-4bb1-9db3-9c9f2b2e2d87", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "action", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 15, "action": {"name": "spotify__like_song", "arguments": {"song_id": 272, "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzcG90aWZ5K2NhaWJ1cmNAZ21haWwuY29tIiwiZXhwIjoxNjg0NDEyMDk4fQ.7pIZgwN8dQjGbediu4d8La107fF_vrxRODqJOls1Ek0"}, "id": "9f77799f-da2b-4d4c-9a41-ac5ad7aa6d3e", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 15, "observation": {"result": {"message": "Song liked."}, "invoking_actions": [{"name": "spotify__like_song", "arguments": {}, "id": "9f77799f-da2b-4d4c-9a41-ac5ad7aa6d3e", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "action", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 16, "action": {"name": "spotify__like_song", "arguments": {"song_id": 101, "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzcG90aWZ5K2NhaWJ1cmNAZ21haWwuY29tIiwiZXhwIjoxNjg0NDEyMDk4fQ.7pIZgwN8dQjGbediu4d8La107fF_vrxRODqJOls1Ek0"}, "id": "f0e109be-84bf-4442-82b2-db8d33980f5e", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 16, "observation": {"result": {"message": "Song liked."}, "invoking_actions": [{"name": "spotify__like_song", "arguments": {}, "id": "f0e109be-84bf-4442-82b2-db8d33980f5e", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "action", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 17, "action": {"name": "spotify__like_song", "arguments": {"song_id": 318, "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzcG90aWZ5K2NhaWJ1cmNAZ21haWwuY29tIiwiZXhwIjoxNjg0NDEyMDk4fQ.7pIZgwN8dQjGbediu4d8La107fF_vrxRODqJOls1Ek0"}, "id": "7b722a74-b1a1-4c41-a6c0-b6013481ba30", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 17, "observation": {"result": {"message": "Song liked."}, "invoking_actions": [{"name": "spotify__like_song", "arguments": {}, "id": "7b722a74-b1a1-4c41-a6c0-b6013481ba30", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "action", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 18, "action": {"name": "spotify__like_song", "arguments": {"song_id": 94, "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzcG90aWZ5K2NhaWJ1cmNAZ21haWwuY29tIiwiZXhwIjoxNjg0NDEyMDk4fQ.7pIZgwN8dQjGbediu4d8La107fF_vrxRODqJOls1Ek0"}, "id": "ca64d869-ec0f-4e8f-9562-fe0a90c268b2", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 18, "observation": {"result": {"message": "Song liked."}, "invoking_actions": [{"name": "spotify__like_song", "arguments": {}, "id": "ca64d869-ec0f-4e8f-9562-fe0a90c268b2", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "action", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 19, "action": {"name": "spotify__like_song", "arguments": {"song_id": 146, "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzcG90aWZ5K2NhaWJ1cmNAZ21haWwuY29tIiwiZXhwIjoxNjg0NDEyMDk4fQ.7pIZgwN8dQjGbediu4d8La107fF_vrxRODqJOls1Ek0"}, "id": "351bf9fe-9e27-4f91-9c83-8da1b8a13570", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 19, "observation": {"result": {"message": "Song liked."}, "invoking_actions": [{"name": "spotify__like_song", "arguments": {}, "id": "351bf9fe-9e27-4f91-9c83-8da1b8a13570", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "action", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 20, "action": {"name": "spotify__like_song", "arguments": {"song_id": 194, "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzcG90aWZ5K2NhaWJ1cmNAZ21haWwuY29tIiwiZXhwIjoxNjg0NDEyMDk4fQ.7pIZgwN8dQjGbediu4d8La107fF_vrxRODqJOls1Ek0"}, "id": "155999da-6a27-4292-bbe7-8c6dd1b1ea48", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 20, "observation": {"result": {"message": "Song liked."}, "invoking_actions": [{"name": "spotify__like_song", "arguments": {}, "id": "155999da-6a27-4292-bbe7-8c6dd1b1ea48", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "action", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 21, "action": {"name": "spotify__like_song", "arguments": {"song_id": 128, "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzcG90aWZ5K2NhaWJ1cmNAZ21haWwuY29tIiwiZXhwIjoxNjg0NDEyMDk4fQ.7pIZgwN8dQjGbediu4d8La107fF_vrxRODqJOls1Ek0"}, "id": "30a785ef-7ef3-4f67-8bdd-2d90d424176f", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 21, "observation": {"result": {"message": "Song liked."}, "invoking_actions": [{"name": "spotify__like_song", "arguments": {}, "id": "30a785ef-7ef3-4f67-8bdd-2d90d424176f", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "action", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 22, "action": {"name": "spotify__like_song", "arguments": {"song_id": 62, "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzcG90aWZ5K2NhaWJ1cmNAZ21haWwuY29tIiwiZXhwIjoxNjg0NDEyMDk4fQ.7pIZgwN8dQjGbediu4d8La107fF_vrxRODqJOls1Ek0"}, "id": "d65efdec-de17-48bf-b510-f0fb09fa5473", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 22, "observation": {"result": {"message": "Song liked."}, "invoking_actions": [{"name": "spotify__like_song", "arguments": {}, "id": "d65efdec-de17-48bf-b510-f0fb09fa5473", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "action", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 23, "action": {"name": "spotify__like_song", "arguments": {"song_id": 143, "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzcG90aWZ5K2NhaWJ1cmNAZ21haWwuY29tIiwiZXhwIjoxNjg0NDEyMDk4fQ.7pIZgwN8dQjGbediu4d8La107fF_vrxRODqJOls1Ek0"}, "id": "041084be-b4d9-4e1e-b0e8-1cf8bd1d8f5d", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 23, "observation": {"result": {"message": "Song liked."}, "invoking_actions": [{"name": "spotify__like_song", "arguments": {}, "id": "041084be-b4d9-4e1e-b0e8-1cf8bd1d8f5d", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "action", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 24, "action": {"name": "spotify__like_song", "arguments": {"song_id": 34, "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzcG90aWZ5K2NhaWJ1cmNAZ21haWwuY29tIiwiZXhwIjoxNjg0NDEyMDk4fQ.7pIZgwN8dQjGbediu4d8La107fF_vrxRODqJOls1Ek0"}, "id": "f758a3f0-a9b8-47b7-a393-956023c867ca", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 24, "observation": {"result": {"message": "Song liked."}, "invoking_actions": [{"name": "spotify__like_song", "arguments": {}, "id": "f758a3f0-a9b8-47b7-a393-956023c867ca", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "action", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 25, "action": {"name": "spotify__like_album", "arguments": {"album_id": 8, "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzcG90aWZ5K2NhaWJ1cmNAZ21haWwuY29tIiwiZXhwIjoxNjg0NDEyMDk4fQ.7pIZgwN8dQjGbediu4d8La107fF_vrxRODqJOls1Ek0"}, "id": "b7fc2451-c992-414c-93b1-a7baaf05b88c", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 25, "observation": {"result": {"message": "Album liked."}, "invoking_actions": [{"name": "spotify__like_album", "arguments": {}, "id": "b7fc2451-c992-414c-93b1-a7baaf05b88c", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "action", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 26, "action": {"name": "spotify__like_album", "arguments": {"album_id": 3, "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzcG90aWZ5K2NhaWJ1cmNAZ21haWwuY29tIiwiZXhwIjoxNjg0NDEyMDk4fQ.7pIZgwN8dQjGbediu4d8La107fF_vrxRODqJOls1Ek0"}, "id": "f53d1648-77d8-471c-844a-19bcce0dc524", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 26, "observation": {"result": {"message": "Album liked."}, "invoking_actions": [{"name": "spotify__like_album", "arguments": {}, "id": "f53d1648-77d8-471c-844a-19bcce0dc524", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "action", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 27, "action": {"name": "spotify__like_album", "arguments": {"album_id": 5, "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzcG90aWZ5K2NhaWJ1cmNAZ21haWwuY29tIiwiZXhwIjoxNjg0NDEyMDk4fQ.7pIZgwN8dQjGbediu4d8La107fF_vrxRODqJOls1Ek0"}, "id": "5797fd42-eff8-494e-b7ce-9074d09c68bb", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 27, "observation": {"result": {"message": "Album liked."}, "invoking_actions": [{"name": "spotify__like_album", "arguments": {}, "id": "5797fd42-eff8-494e-b7ce-9074d09c68bb", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "action", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 28, "action": {"name": "spotify__like_album", "arguments": {"album_id": 18, "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzcG90aWZ5K2NhaWJ1cmNAZ21haWwuY29tIiwiZXhwIjoxNjg0NDEyMDk4fQ.7pIZgwN8dQjGbediu4d8La107fF_vrxRODqJOls1Ek0"}, "id": "855a8cde-f525-40d3-bd11-1c5c73480999", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 28, "observation": {"result": {"message": "Album liked."}, "invoking_actions": [{"name": "spotify__like_album", "arguments": {}, "id": "855a8cde-f525-40d3-bd11-1c5c73480999", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "action", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 29, "action": {"name": "spotify__like_album", "arguments": {"album_id": 9, "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzcG90aWZ5K2NhaWJ1cmNAZ21haWwuY29tIiwiZXhwIjoxNjg0NDEyMDk4fQ.7pIZgwN8dQjGbediu4d8La107fF_vrxRODqJOls1Ek0"}, "id": "cc589b5e-f138-4065-9688-fbf29f1f0058", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 29, "observation": {"result": {"message": "Album liked."}, "invoking_actions": [{"name": "spotify__like_album", "arguments": {}, "id": "cc589b5e-f138-4065-9688-fbf29f1f0058", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "action", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 30, "action": {"name": "spotify__like_album", "arguments": {"album_id": 14, "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzcG90aWZ5K2NhaWJ1cmNAZ21haWwuY29tIiwiZXhwIjoxNjg0NDEyMDk4fQ.7pIZgwN8dQjGbediu4d8La107fF_vrxRODqJOls1Ek0"}, "id": "68e74b9b-0922-4e43-afe8-a0333ce293d0", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 30, "observation": {"result": {"message": "Album liked."}, "invoking_actions": [{"name": "spotify__like_album", "arguments": {}, "id": "68e74b9b-0922-4e43-afe8-a0333ce293d0", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 3.17682, "benchmark_cost": 0} +{"event": "action", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 31, "action": {"name": "finish", "arguments": {"answer": null, "status": "success"}, "id": "589d540c-af44-4931-a7dc-33a4c5823b96", "validation": {"valid": false, "name_valid": true, "args_valid": false, "error": "Field 'arguments.answer.float': Input should be a valid number (received None of type NoneType).Field 'arguments.answer.int': Input should be a valid integer (received None of type NoneType).Field 'arguments.answer.str': Input should be a valid string (received None of type NoneType).", "details": {"errors": [{"type": "float_type", "loc": ["arguments", "answer", "float"], "msg": "Input should be a valid number", "input": null, "url": "https://errors.pydantic.dev/2.12/v/float_type"}, {"type": "int_type", "loc": ["arguments", "answer", "int"], "msg": "Input should be a valid integer", "input": null, "url": "https://errors.pydantic.dev/2.12/v/int_type"}, {"type": "string_type", "loc": ["arguments", "answer", "str"], "msg": "Input should be a valid string", "input": null, "url": "https://errors.pydantic.dev/2.12/v/string_type"}]}}}, "initial": false, "agent_cost": 3.7826250000000003, "benchmark_cost": 0} +{"event": "observation", "run_id": "0c890a5dde8c", "session_id": "9e993ba4", "session_number": 39, "task_id": "f3f60f0_3", "step": 31, "observation": {"result": {"message": "Marked the active task complete."}, "invoking_actions": [{"name": "finish", "arguments": {}, "id": "589d540c-af44-4931-a7dc-33a4c5823b96", "validation": {"valid": false, "name_valid": true, "args_valid": false, "error": "Field 'arguments.answer.float': Input should be a valid number (received None of type NoneType).Field 'arguments.answer.int': Input should be a valid integer (received None of type NoneType).Field 'arguments.answer.str': Input should be a valid string (received None of type NoneType).", "details": {"errors": [{"type": "float_type", "loc": ["arguments", "answer", "float"], "msg": "Input should be a valid number", "input": null, "url": "https://errors.pydantic.dev/2.12/v/float_type"}, {"type": "int_type", "loc": ["arguments", "answer", "int"], "msg": "Input should be a valid integer", "input": null, "url": "https://errors.pydantic.dev/2.12/v/int_type"}, {"type": "string_type", "loc": ["arguments", "answer", "str"], "msg": "Input should be a valid string", "input": null, "url": "https://errors.pydantic.dev/2.12/v/string_type"}]}}}]}, "initial": false, "agent_cost": 3.7826250000000003, "benchmark_cost": 0} diff --git a/labs/AgentStream/exgentic/tests/benchmarks/recordings/browsecompplus/recording.json b/labs/AgentStream/exgentic/tests/benchmarks/recordings/browsecompplus/recording.json new file mode 100644 index 00000000..635f6557 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/benchmarks/recordings/browsecompplus/recording.json @@ -0,0 +1,5 @@ +{ + "benchmark": "browsecompplus", + "task_id": "62", + "expected_score": 1.0 +} diff --git a/labs/AgentStream/exgentic/tests/benchmarks/recordings/browsecompplus/results.json b/labs/AgentStream/exgentic/tests/benchmarks/recordings/browsecompplus/results.json new file mode 100644 index 00000000..e9a91dce --- /dev/null +++ b/labs/AgentStream/exgentic/tests/benchmarks/recordings/browsecompplus/results.json @@ -0,0 +1,206 @@ +{ + "session_id": "2cfd9083", + "success": true, + "score": 1.0, + "is_finished": true, + "steps": 27, + "agent_cost": 6.4443850000000005, + "benchmark_cost": 0.0019039999999999999, + "execution_time": 2.1636428833007812, + "details": { + "score": 1.0, + "success": true, + "is_finished": true, + "session_metrics": { + "Accuracy": 1, + "Retrieval_recall": 1.0, + "Citation_metrics_positives": { + "num_citations": 4, + "num_relevant": 4, + "precision": 1.0, + "recall": 1.0 + }, + "Confidence": [ + 85.0 + ] + }, + "session_metadata": { + "instance": { + "task_id": 62, + "query_id": 1262, + "query": "Set in 19th-century London, the story follows a protagonist who, while in disguise, becomes involved in helping a businessman avert a disastrous deal. However, the situation takes a dire turn when the businessman accuses the protagonist of stealing a vital document. As the events unravel, the two uncover the real culprit\u2014a schemer plotting to send the encrypted document abroad. In a dramatic climax during a fierce sea storm, the businessman risks their life to rescue the protagonist, securing their safety. Name this historical fiction novel published before 2017.", + "gold_answer": "Storm and Silence", + "gold_docs": [ + "71659", + "84371", + "90027", + "25654" + ], + "evidence_docs": [ + "71659", + "84371", + "90027", + "25654" + ], + "negative_docs": [ + "98862", + "51847", + "51328", + "78562", + "45315", + "49802", + "22514", + "96201", + "77939", + "19305", + "81929", + "53914", + "17573", + "3290", + "21054", + "25997", + "97644", + "37321", + "99618", + "21656", + "43110", + "67862", + "72751", + "43897", + "577", + "22504", + "77980", + "77101", + "24890", + "52189", + "48934", + "50537", + "48165", + "7436", + "29367", + "48523", + "42402", + "4486", + "72600", + "42367", + "59534", + "62735", + "34365", + "44910", + "96757", + "48063", + "802", + "28378", + "4235", + "74026", + "29859", + "24479", + "82097", + "29668", + "2526", + "7498", + "29526", + "61641", + "3054", + "81725", + "34147", + "34764", + "99787", + "85973", + "44817", + "50973", + "69577", + "12540", + "51857", + "68049", + "42735", + "36626", + "73898", + "95393", + "67018", + "91840", + "39072", + "87057", + "13060", + "17199", + "6198", + "63278", + "82109", + "32850", + "59389", + "93904", + "22055", + "11299", + "64166", + "69228" + ] + }, + "retrieved_docids": [ + "20048", + "93624", + "97644", + "78562", + "71386", + "1891", + "65464", + "41618", + "45315", + "29367", + "79197", + "81929", + "1351", + "95934", + "99787", + "72764", + "74026", + "18321", + "66065", + "72751", + "82350", + "67868", + "38361", + "19305", + "33677", + "32872", + "5574", + "90027", + "71659", + "77939", + "53914", + "13697", + "51328", + "29859", + "36824", + "60123", + "96202", + "7830", + "48916", + "84371", + "42402", + "27430", + "25654" + ], + "response": "{\"exact_answer\": \"Storm and Silence\", \"explanation\": \"The historical fiction novel described is \\\"Storm and Silence\\\" by Robert Thier, published in March 2016. The novel is set in 19th-century London and follows Lilly Linton, a young feminist who disguises herself as a man to work for the ruthless financial business magnate Mr. Rikkard Ambrose. [90027, 25654, 71659]\\n\\nAccording to the author interview, \\\"The story follows the adventures of 19-year-old feminist Lilly and her budding romance with the powerful and ruthless business mogul Rikkard Ambrose. A firm believer in the equality of men and women, Lilly is forced to dress up as a man in order to work for Mr. Ambrose and achieve her independence. The two of them butt heads all the time, but when an important document is stolen from Mr. Ambrose's safe, and he has fought a battle of wits and intrigue with his arch-enemy Lord Dalgliesh, she stands by him through thick and thin.\\\" [84371]\\n\\nThe chapter list includes titles like \\\"The Thief,\\\" \\\"File Fight,\\\" \\\"Isle Marbeau,\\\" \\\"Rising Waves,\\\" \\\"Shots in the Dark,\\\" and \\\"Man and Woman\\\" at the end of the book, indicating the plot elements involving a stolen document, an island setting, and dramatic sea climax with the rescue. [90027]\\n\\nThe novel was first uploaded on Wattpad in April 2014 and was officially published as a paperback and ebook in March 2016, meeting the criteria of being published before 2017. [90027, 25654]\", \"confidence\": 85.0}", + "extracted_final_answer": "Storm and Silence", + "judge_model": "openai/Azure/gpt-4.1", + "is_complete": true, + "judge_parse_error": false, + "tool_call_counts": { + "search": 22, + "get_document": 4, + "submit": 1 + } + } + }, + "cost_reports": { + "agent": { + "model_name": "openai/aws/claude-opus-4-5", + "total_cost": 6.4443850000000005 + }, + "benchmark": { + "model_name": "openai/Azure/gpt-4.1", + "total_cost": 0.0019039999999999999 + } + }, + "task_key": "1262" +} diff --git a/labs/AgentStream/exgentic/tests/benchmarks/recordings/browsecompplus/session.json b/labs/AgentStream/exgentic/tests/benchmarks/recordings/browsecompplus/session.json new file mode 100644 index 00000000..aa18cfbb --- /dev/null +++ b/labs/AgentStream/exgentic/tests/benchmarks/recordings/browsecompplus/session.json @@ -0,0 +1,79 @@ +{ + "task": "Answer the provided question by performing search and document expansion as needed, and submit your final answer. \nQuestion: Set in 19th-century London, the story follows a protagonist who, while in disguise, becomes involved in helping a businessman avert a disastrous deal. However, the situation takes a dire turn when the businessman accuses the protagonist of stealing a vital document. As the events unravel, the two uncover the real culprit—a schemer plotting to send the encrypted document abroad. In a dramatic climax during a fierce sea storm, the businessman risks their life to rescue the protagonist, securing their safety. Name this historical fiction novel published before 2017.\nNote:\n- The question has an answer discoverable through proper search.\n- The question requires putting together information from different sources.\n\nYour performance is scored based on:\n 1. Most importantly, the correctness of the answer you assembled from different searches.\n 2. Your effective use of search and your ability to retrieve all relevant information for the question.\n 3. How efficiently you find all the relevant information, using as few searches as possible.\n\nImportant: During your work, Do NOT interact with the user or send any messages at any point — messages will be ignored and are NOT considered a valid final answer. The ONLY acceptable way to finish is by calling 'submit' with the required structured fields.\n\nFinish the session always by calling `submit`. If you fail to find the answer, submit with exact_answer: \"Can't find the answer.\".", + "context": {}, + "actions": [ + { + "name": "search", + "description": "Perform a search on a knowledge source: supply a single 'query' string; the action retrieves the 5 top most relevant results, each trimmed to 512 tokens.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "query": { + "title": "Query", + "type": "string" + } + }, + "required": [ + "query" + ], + "title": "BrowseCompPlusSearchArgs", + "type": "object" + } + }, + { + "name": "submit", + "description": "Submit final answer and complete", + "is_finish": true, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "exact_answer": { + "description": "Your succinct, final answer", + "title": "Exact Answer", + "type": "string" + }, + "explanation": { + "description": "Your explanation for your final answer. For this explanation section only, you should cite your evidence documents inline by enclosing their docids in square brackets [] at the end of sentences. For example, [20].", + "title": "Explanation", + "type": "string" + }, + "confidence": { + "description": "Your confidence score between 0% and 100% for your answer", + "title": "Confidence", + "type": "number" + } + }, + "required": [ + "exact_answer", + "explanation", + "confidence" + ], + "title": "BrowseCompPlusFinishArgs", + "type": "object" + } + }, + { + "name": "get_document", + "description": "Retrieve the full document using its document id", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "docid": { + "title": "Docid", + "type": "string" + } + }, + "required": [ + "docid" + ], + "title": "BrowseCompPlusGetDocumentsArgs", + "type": "object" + } + } + ] +} diff --git a/labs/AgentStream/exgentic/tests/benchmarks/recordings/browsecompplus/trajectory.jsonl b/labs/AgentStream/exgentic/tests/benchmarks/recordings/browsecompplus/trajectory.jsonl new file mode 100644 index 00000000..67f5da84 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/benchmarks/recordings/browsecompplus/trajectory.jsonl @@ -0,0 +1,55 @@ +{"event": "observation", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 0, "observation": {"result": null, "invoking_actions": []}, "initial": true, "agent_cost": 0, "benchmark_cost": 0.0} +{"event": "action", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 1, "action": {"name": "search", "arguments": {"query": "19th century London historical fiction novel disguise businessman stolen document sea storm rescue"}, "id": "35e7bc3a-50a6-4078-bc33-7acc7c0128fd", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.00807, "benchmark_cost": 0.0} +{"event": "observation", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 1, "observation": {"result": "[\n {\n \"docid\": \"29367\",\n \"score\": 0.5636506080627441,\n \"snippet\": \"---\\ntitle: Historical Fictionistas discussion\\nauthor: Author add link photo\\ndate: 2012-10-11\\n---\\nHistorical Fictionistas discussion\\n\\nRecommendations?\\n\\n>\\n\\nCrime in 19th Century London?\\n\\ndate\\n\\nnewest \\u00bb\\n\\nmessage 1:\\n\\nby\\n\\nAllison\\n\\n(new)\\n\\nOct 11, 2012 07:57AM\\n\\nHey you guys...I would love some recommendations on books set in 19th century London revolving around crime (other than Jack the Ripper). Fiction and non-fiction would both be great!\\n\\nreply\\n\\n|\\n\\nflag\\n\\nAnything by Anne Perry in the Inspector Monk and the Thomas Pitt series. They start with The Face of a Stranger and The Cater Street Hangman, respectively. Monk is 1860s and Pitt somewhat later.\\n\\nDickens, of course. But no one does 19th-century London crime quite like Perry.\\n\\nSome of Tasha Alexander's Lady Emily Ashton novels, starting with And Only to Deceive, are also set in London, but they are more oriented toward elite crime than the nitty-gritty grim reality of the Isle of Dogs.\\n\\nDickens, of course. But no one does 19th-century London crime quite like Perry.\\n\\nSome of Tasha Alexander's Lady Emily Ashton novels, starting with And Only to Deceive, are also set in London, but they are more oriented toward elite crime than the nitty-gritty grim reality of the Isle of Dogs.\\n\\nDavid Liss's Benjamin Weaver series is my favorite. It begins with A Conspiracy of Paper, a HF murder/mystery/thriller novel about a financial crisis in 19th century London. You'll love Benjamin and learn a lot about economics (without being bored to tears) because Liss is such a master at inserting historical details in creative ways. David Liss\\n\\nI love historical mysteries, so here's a few of my favorites set in London in the 19th century:\\n\\nThe Yard\\n\\nThe Solitary House\\n\\nA gritty series:\\n\\nWill Thomas, the first title isSome Danger Involved\\n\\nA couple of aristocratic series:\\n\\nC.S. Harris, Regency era with the first in the series being What Angels Fear\\n\\nCharles Finch,A Beautiful Blue Death is first in the series\\n\\nEarly forensics:\\n\\nDevoured by D.E. Meredith\\n\\nEnjoy!\\n\\nThe Yard\\n\\nThe Solitary House\\n\\nA gritty series:\\n\\nWill Thomas, the first title isSome Danger Involved\\n\\nA couple of arist\"\n },\n {\n \"docid\": \"19305\",\n \"score\": 0.5559988617897034,\n \"snippet\": \"---\\ntitle: Ten of the best disguises in literature\\nauthor: John Mullan\\ndate: 2010-09-25\\n---\\nThe Odyssey, by Homer\\n\\nOdysseus arrives back at his island of Ithaca disguised as a beggar. He is recognised only by his old dog Argus (animals always see through disguises), which dies of joy on the spot. In his disguise, our hero is able to see who has been loyal to him and who has not.\\n\\nMeasure for Measure, by William Shakespeare\\n\\nThe Duke who governs Vienna wants to see what his underlings will get up to in his absence. So he asks his friend Friar Thomas for some monkish garb: \\\"Supply me with the habit and instruct me / How I may formally in person bear me / Like a true friar\\\". It works, and not even his most devoted courtiers recognise him until he finally unveils himself.\\n\\nThe Monk, by Matthew Lewis\\n\\nAnother monkish disguise. Sexy young Matilda lusts after Father Ambrosio, the most pious monk in Madrid. So she dresses up as a young novice monk and finds her way into the monastery. In her cell she reveals herself to Ambrosio, who cannot resist her charms. It turns out that she is in fact a demon.\\n\\nJane Eyre, by Charlotte Bront\\u00eb\\n\\nOne of the great episodes of transvestism in literature comes when Rochester togs himself up as a Gypsy woman to read the palms of the guests he has invited to Thornfield. Blanche Ingram, Jane's rival for his affections, gets uncomforting news, but Jane is told \\\"the cup of bliss\\\" is going to be offered to her.\\n\\nEast Lynne, by Mrs Henry Wood\\n\\nLady Isabel Vane loses her happy home and family when she conducts an adulterous affair with the utterly caddish Francis Levinson. Having learned the error of her ways, she returns to be governess to her own children, disguised by blue-lensed glasses, hair turned white from shock after a train crash and a scarred mouth.\\n\\nThe Mystery of Edwin Drood, by Charles Dickens\\n\\nDick Datchery arrives in the town of Cloisterham, apparently a detective in disguise (he wears a wig). He (or she?) keeps watch over John Jasper, choirmaster and secret drug addict. Drood has disappeared: is he disguised as Datchery? Or is it another character, investigating Drood's murder? Dickens did\"\n },\n {\n \"docid\": \"1351\",\n \"score\": 0.5189502239227295,\n \"snippet\": \"---\\ntitle: 18th Century Historical Fiction\\nauthor: Charles Dickens\\ndate: 2025-01-01\\n---\\n| 1 |\\n\\n|\\n\\nA Tale of Two Cities\\n\\nby\\n\\n3.88 avg rating \\u2014 992,065 ratings\\n\\n|\\n\\n|\\n\\n| 2 |\\n\\n|\\n\\nLes Liaisons dangereuses\\n\\nby\\n\\n4.05 avg rating \\u2014 56,816 ratings\\n\\n|\\n\\n|\\n\\n| 3 |\\n\\n|\\n\\nOutlander (Outlander, #1)\\n\\nby\\n\\n4.26 avg rating \\u2014 1,126,189 ratings\\n\\n|\\n\\n|\\n\\n| 4 |\\n\\n|\\n\\nTreasure Island\\n\\nby\\n\\n3.85 avg rating \\u2014 524,777 ratings\\n\\n|\\n\\n|\\n\\n| 5 |\\n\\n|\\n\\nThe London Monster\\n\\nby\\n\\n4.10 avg rating \\u2014 185 ratings\\n\\n|\\n\\n|\\n\\n| 6 |\\n\\n|\\n\\nRoss Poldark (Poldark, #1)\\n\\nby\\n\\n4.08 avg rating \\u2014 34,572 ratings\\n\\n|\\n\\n|\\n\\n| 7 |\\n\\n|\\n\\nThe Winter Sea (Slains, #1)\\n\\nby\\n\\n4.11 avg rating \\u2014 68,603 ratings\\n\\n|\\n\\n|\\n\\n| 8 |\\n\\n|\\n\\nThe Scarlet Pimpernel\\n\\nby\\n\\n4.07 avg rating \\u2014 139,637 ratings\\n\\n|\\n\\n|\\n\\n| 9 |\\n\\n|\\n\\nThrough a Glass Darkly (Tamworth Saga, #2)\\n\\nby\\n\\n3.97 avg rating \\u2014 11,323 ratings\\n\\n|\\n\\n|\\n\\n| 10 |\\n\\n|\\n\\nLord John And The Private Matter (Lord John Grey Book 1)\\n\\nby\\n\\n3.76 avg rating \\u2014 49,766 ratings\\n\\n|\\n\\n|\\n\\n| 11 |\\n\\n|\\n\\nAnn & Frederick\\n\\nby\\n\\n3.88 avg rating \\u2014 8 ratings\\n\\n|\\n\\n|\\n\\n| 12 |\\n\\n|\\n\\nBecoming Marie Antoinette (Marie Antoinette, #1)\\n\\nby\\n\\n3.81 avg rating \\u2014 6,665 ratings\\n\\n|\\n\\n|\\n\\n| 13 |\\n\\n|\\n\\nThe Mysteries of Udolpho\\n\\nby\\n\\n3.42 avg rating \\u2014 16,480 ratings\\n\\n|\\n\\n|\\n\\n| 14 |\\n\\n|\\n\\nMadame Tussaud: A Novel of the French Revolution\\n\\nby\\n\\n3.97 avg rating \\u2014 22,264 ratings\\n\\n|\\n\\n|\\n\\n|\"\n },\n {\n \"docid\": \"27430\",\n \"score\": 0.5145552158355713,\n \"snippet\": \"---\\ntitle: Read like it's 1821: 10 books that hit the book market 200 years ago for you to enjoy\\nauthor: Anna M Thane\\ndate: 2021-01-20\\n---\\nIf you were a time traveller in 1821 longing for a good read, what would be your options?\\n\\nCheck out my list of popular fiction and non-fiction releases. I have added links to online versions of each book, so you can actually read like its 1821!\\n\\nBonus feature: Suggestions for further reading on each topic from today's experts on the 18th century.\\n\\nJames Fenimore Cooper: \\\"The Spy: a Tale of the Neutral Ground\\\".\\n\\nIt is 1780, a period of conflicts and espionage between military and guerrilla forces in America. Main character Harvey Birch comes under suspicion for being a British spy. Also around: the mysterious Mr. Harper, actually George Washington in disguise\\u2026 . Mr. Cooper's second novel is based on a true story of the Revolutionary War.\\n\\nRead it online here: \\n\\nAre you interested in the Revolutionary War? Here are suggestions for further reading:\\n\\n- Washington's Revolutionary War Generals (Campaigns and Commanders) by Stephen R. Taaffe\\n\\n- A History of the Royal Navy : The American Revolutionary War by Martin Robson\\n\\n- A War of Ideas: British Attitudes to the Wars Against Revolutionary France, 1792-1802 (Routledge Revivals) by Emma Vincent Macleod\\n\\nAnna Maria Porter's \\\"The village of Mariendorpt\\\"\\n\\nThe historical romance takes you to the year 1631. Rupert and Meeta have to fight for their happiness during the Protestant War in Germany and the siege of Magdeburg. It's the 14. novel by the prolific writer, and it was turned into a stage drama in 1838. The critics are mixed: \\\"(It is) full of the most touching passages, but, as a whole, it drags. Her knowledge of military details appears to me marvellous\\\", writes fellow author Sarah Harriet Burney in a letter.\\n\\nRead it online here: \\n\\nAre you interested in female authors of the the 18th century? Here is a suggestion for further reading:\\n\\n- Material Enlightenment: Women Writers and the Science of Mind, 1770-1830 (Studies in the Eighteenth Century) by Joanna Wharton\"\n },\n {\n \"docid\": \"42402\",\n \"score\": 0.5138538479804993,\n \"snippet\": \"---\\ntitle: The Scarlet Pimpernel - Wikipedia\\nauthor: Authority control databases\\ndate: 2002-09-10\\n---\\nname: The Scarlet Pimpernel\\nauthor: Baroness Orczy\\ncountry: United Kingdom\\nlanguage: English\\ngenre: Historical fiction, adventure fiction\\nset_in: 1792, during the early stages of the French Revolution\\npublisher: Greening\\npub_date: 1905\\npages: 319\\npreceded_by: The First Sir Percy\\nfollowed_by: Sir Percy Leads the Band\\n\\nThe Scarlet Pimpernel is the first novel in a series of historical fiction by Baroness Orczy, published in 1905. It was written after her stage play of the same title (co-authored with her husband Montague Barstow) enjoyed a long run in London, having opened in Nottingham in 1903.\\n\\nThe novel is set during the Reign of Terror following the start of the French Revolution. The title is the nom de guerre of its hero and protagonist, a chivalrous Englishman who rescues aristocrats before they are sent to the guillotine. Sir Percy Blakeney leads a double life: apparently nothing more than a wealthy fop, but in reality, a formidable swordsman and a quick-thinking master of disguise and escape artist. The band of gentlemen who assist him are the only ones who know of his secret identity. He is known by his symbol, a simple flower, the scarlet pimpernel (Anagallis arvensis).\\n\\nOpening at the New Theatre in London's West End on 5 January 1905, the play became a favourite of British audiences, eventually playing more than 2,000 performances and becoming one of the most popular shows staged in London. Published after the success of the play, the novel was an immediate success, gaining Orczy a following of readers in Britain and the rest of the world. The stage play and subsequent novel, with their hero and villain, were so popular that they inspired a revival of classic villainy at the time.\\n\\nOrczy's premise of a daring hero who cultivates a secret identity disguised by a meek or ineffectual manner proved enduring. Zorro, Doctor Syn, the Shadow, the Spider, the Green Hornet, the Phantom, Superman and Batman followed within a few decades, and the trope remains a popular one in serial fiction today. Read by\"\n }\n]", "invoking_actions": [{"name": "search", "arguments": {}, "id": "35e7bc3a-50a6-4078-bc33-7acc7c0128fd", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.00807, "benchmark_cost": 0.0} +{"event": "action", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 2, "action": {"name": "search", "arguments": {"query": "historical fiction Victorian London encrypted document stolen accusation sea storm rescue businessman"}, "id": "8cb4737d-f313-4b87-a55a-eb0d77b950ab", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.035750000000000004, "benchmark_cost": 0.0} +{"event": "observation", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 2, "observation": {"result": "[\n {\n \"docid\": \"29367\",\n \"score\": 0.6082387566566467,\n \"snippet\": \"---\\ntitle: Historical Fictionistas discussion\\nauthor: Author add link photo\\ndate: 2012-10-11\\n---\\nHistorical Fictionistas discussion\\n\\nRecommendations?\\n\\n>\\n\\nCrime in 19th Century London?\\n\\ndate\\n\\nnewest \\u00bb\\n\\nmessage 1:\\n\\nby\\n\\nAllison\\n\\n(new)\\n\\nOct 11, 2012 07:57AM\\n\\nHey you guys...I would love some recommendations on books set in 19th century London revolving around crime (other than Jack the Ripper). Fiction and non-fiction would both be great!\\n\\nreply\\n\\n|\\n\\nflag\\n\\nAnything by Anne Perry in the Inspector Monk and the Thomas Pitt series. They start with The Face of a Stranger and The Cater Street Hangman, respectively. Monk is 1860s and Pitt somewhat later.\\n\\nDickens, of course. But no one does 19th-century London crime quite like Perry.\\n\\nSome of Tasha Alexander's Lady Emily Ashton novels, starting with And Only to Deceive, are also set in London, but they are more oriented toward elite crime than the nitty-gritty grim reality of the Isle of Dogs.\\n\\nDickens, of course. But no one does 19th-century London crime quite like Perry.\\n\\nSome of Tasha Alexander's Lady Emily Ashton novels, starting with And Only to Deceive, are also set in London, but they are more oriented toward elite crime than the nitty-gritty grim reality of the Isle of Dogs.\\n\\nDavid Liss's Benjamin Weaver series is my favorite. It begins with A Conspiracy of Paper, a HF murder/mystery/thriller novel about a financial crisis in 19th century London. You'll love Benjamin and learn a lot about economics (without being bored to tears) because Liss is such a master at inserting historical details in creative ways. David Liss\\n\\nI love historical mysteries, so here's a few of my favorites set in London in the 19th century:\\n\\nThe Yard\\n\\nThe Solitary House\\n\\nA gritty series:\\n\\nWill Thomas, the first title isSome Danger Involved\\n\\nA couple of aristocratic series:\\n\\nC.S. Harris, Regency era with the first in the series being What Angels Fear\\n\\nCharles Finch,A Beautiful Blue Death is first in the series\\n\\nEarly forensics:\\n\\nDevoured by D.E. Meredith\\n\\nEnjoy!\\n\\nThe Yard\\n\\nThe Solitary House\\n\\nA gritty series:\\n\\nWill Thomas, the first title isSome Danger Involved\\n\\nA couple of arist\"\n },\n {\n \"docid\": \"77939\",\n \"score\": 0.5877675414085388,\n \"snippet\": \"---\\ntitle: 12 Historical Fiction Books That Bring the Victorian Era to Life\\ndate: 2024-07-09\\n---\\nFeatures summary\\n\\n12 Historical Fiction Books That Bring the Victorian Era to Life\\n\\nImmerse yourself in the Victorian era with these 12 captivating historical fiction novels. Each book brings to life the complexities, challenges, and beauty of the period, offering a detailed look at the social, cultural, and technological changes of the time.\\n\\n12 Historical Fiction Books That Bring the Victorian Era to Life (Picture Credit - Instagram)\\n\\nThe Victorian era, spanning from 1837 to 1901, was a period of significant social, cultural, and technological change. This era saw the rise of the British Empire, industrial advancements, and shifts in societal norms. Historical fiction set in this time offers a captivating glimpse into the past, blending rich storytelling with historical details. Here are 12 historical fiction books that beautifully capture the essence of the Victorian era.\\n\\n1. The Crimson Petal and the White by Michel Faber\\n\\nMichel Faber's 'The Crimson Petal and the White' provides a vivid and gritty portrayal of Victorian London. The novel follows Sugar, a determined and intelligent prostitute, as she navigates the city's dark underbelly and attempts to climb the social ladder. Faber's meticulous attention to historical detail and complex characters make this a compelling read that immerses readers in the era's harsh realities and opulent surroundings.\\n\\n2. Fingersmith by Sarah Waters\\n\\n'Fingersmith' by Sarah Waters is a riveting tale of deception, betrayal, and unexpected twists. Set in Victorian England, the story revolves around Sue Trinder, a young woman raised among thieves, and her involvement in a con to defraud a wealthy heiress. Waters' masterful storytelling and rich depiction of Victorian society's class divides and criminal underworld makes this novel a gripping exploration of love and trust.\\n\\n3. The Essex Serpent by Sarah Perry\\n\\nSarah Perry's 'The Essex Serpent' is a beautifully written novel that blends historical fiction with gothic elements. Set in 1893, the story follows Cora Seaborne, a widow with a passion for natural history, as she moves to Essex and investigates rumours of a mythical serpent. Perry's evocative prose and exploration of themes like faith, science, and friendship capture the intellectual and societal currents of the Victorian era.\\n\\n4. The Light Between Oceans by M.L. Sted\"\n },\n {\n \"docid\": \"81929\",\n \"score\": 0.5688050985336304,\n \"snippet\": \"---\\ntitle: 12 Captivating Historical Fiction Books Set in London\\u2014Romance, Mystery and Drama Await!\\ndate: 2025-02-20\\n---\\n12 Captivating Historical Fiction Books Set in London\\u2014Romance, Mystery and Drama Await!\\n\\nFrom intrigue to passionate romance and adventure, these novels are your ticket to a bygone era!\\n\\nEvery major city in the world brims with rich history, storied landmarks and vibrant culture\\u2014but there's nothing quite like historical London. In fact, London's history spans nearly two millennia from Roman times to medieval times, Georgian times and beyond, making it home to some of the most iconic places in the world. Whether you're enthralled by the grandeur of Buckingham Palace or the Houses of Parliament or you can't get enough of certain time periods like the Victorian era or WWII, London truly provides a perfect backdrop for historical fiction novels.\\n\\nSo if you're looking to escape into the past through a London lens, you're in luck! Here, we gathered up 12 historical fiction books set in London\\u2014across bygone eras\\u2014that deliver dynamic storylines (many based on real-life events), memorable characters, high-stakes danger and passionate war-torn love stories.\\n\\nDoes stepping foot into Kew Gardens in 1916 London and embarking on a quest for women's suffrage sound fascinating? Pick up The Kew Garden Girls by Posy Lovell. Lovell's book is inspired by true events and bestselling author Natasha Lester called it \\\"an absolutely charming story about the strength and beauty of female friendship.\\\" If you're more in the mood to experience 1930s London alongside five of the most legendary female writers\\u2014including Agatha Christie\\u2014then pick up The Queens of Crime by Marie Benedict. This historical mystery is part whodunit, part adventure and fully immersive!\\n\\nContinue scrolling for all of our compelling, must-read book recommendations. Cheerio!\\n\\n'The Queens of Crime' by Marie Benedict\\n\\nDanger and intrigue come alive in this tale set in 1930s London and inspired by a true story. The five greatest female crime writers, including Agatha Christie and her legendary rival Dorothy Sayers, form a secret society with one goal: to show their refusal to be treated poorly by their male counterparts. To prove it, they'll solve the actual murder of May Daniels. But the culprit targets Sayers next and threatens to expose a dark secret.\\n\\nWhat readers are saying: \\\"This book is a captivating blend of\"\n },\n {\n \"docid\": \"79197\",\n \"score\": 0.5605930685997009,\n \"snippet\": \"---\\ntitle: Mysterious Circumstances\\nauthor: David Grann\\ndate: 2004-12-13\\n---\\nRichard Lancelyn Green, the world's foremost expert on Sherlock Holmes, believed that he had finally solved the case of the missing papers. Over the past two decades, he had been looking for a trove of letters, diary entries, and manuscripts written by Sir Arthur Conan Doyle, the creator of Holmes. The archive was estimated to be worth nearly four million dollars, and was said by some to carry a deadly curse, like the one in the most famous Holmes story \\\"The Hound of the Baskervilles.\\\"\\n\\nThe papers had disappeared after Conan Doyle died, in 1930, and without them no one had been able to write a definitive biography\\u2014a task that Green was determined to complete. Many scholars feared that the archive had been discarded or destroyed; as the London Times noted earlier this year, its whereabouts had become \\\"a mystery as tantalizing as any to unfold at 221B Baker Street,\\\" the fictional den of Holmes and his fellow-sleuth, Dr. Watson.\\n\\nNot long after Green launched his investigation, he discovered that one of Conan Doyle's five children, Adrian, had, with the other heirs' agreement, stashed the papers in a locked room of a ch\\u00e2teau that he owned in Switzerland. Green then learned that Adrian had spirited some of the papers out of the ch\\u00e2teau without his siblings' knowledge, hoping to sell them to collectors. In the midst of this scheme, he died of a heart attack\\u2014giving rise to the legend of the curse. After Adrian's death, the papers apparently vanished. And whenever Green tried to probe further he found himself caught in an impenetrable web of heirs\\u2014including a self-styled Russian princess\\u2014who seemed to have deceived and double-crossed each other in their efforts to control the archive.\\n\\nFor years, Green continued to sort through evidence and interview relatives, until one day the muddled trail led to London\\u2014and the doorstep of Jean Conan Doyle, the youngest of the author's children. Tall and elegant, with silver hair, she was an imposing woman in her late sixties. (\\\"Something very strong and forceful seems to be at the back of that wee body,\\\" her father had written of Jean when she was five. \\\"Her will is tremendous.\\\") Whereas her brother Adrian had been kicked out of the British Navy for insubordination, and her elder brother Denis was a playboy who had\"\n },\n {\n \"docid\": \"97644\",\n \"score\": 0.5432103872299194,\n \"snippet\": \"---\\ntitle: The 12 Most Electrifying Historical Thrillers Set in Victorian Era\\n---\\nYou need to enable JavaScript to run this app.\"\n }\n]", "invoking_actions": [{"name": "search", "arguments": {}, "id": "8cb4737d-f313-4b87-a55a-eb0d77b950ab", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.035750000000000004, "benchmark_cost": 0.0} +{"event": "action", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 3, "action": {"name": "search", "arguments": {"query": "protagonist disguise businessman stolen document sea storm dramatic rescue Victorian novel"}, "id": "25b83571-eb9d-4413-8581-1ef74f8c696b", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.07896500000000001, "benchmark_cost": 0.0} +{"event": "observation", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 3, "observation": {"result": "[\n {\n \"docid\": \"19305\",\n \"score\": 0.5724257826805115,\n \"snippet\": \"---\\ntitle: Ten of the best disguises in literature\\nauthor: John Mullan\\ndate: 2010-09-25\\n---\\nThe Odyssey, by Homer\\n\\nOdysseus arrives back at his island of Ithaca disguised as a beggar. He is recognised only by his old dog Argus (animals always see through disguises), which dies of joy on the spot. In his disguise, our hero is able to see who has been loyal to him and who has not.\\n\\nMeasure for Measure, by William Shakespeare\\n\\nThe Duke who governs Vienna wants to see what his underlings will get up to in his absence. So he asks his friend Friar Thomas for some monkish garb: \\\"Supply me with the habit and instruct me / How I may formally in person bear me / Like a true friar\\\". It works, and not even his most devoted courtiers recognise him until he finally unveils himself.\\n\\nThe Monk, by Matthew Lewis\\n\\nAnother monkish disguise. Sexy young Matilda lusts after Father Ambrosio, the most pious monk in Madrid. So she dresses up as a young novice monk and finds her way into the monastery. In her cell she reveals herself to Ambrosio, who cannot resist her charms. It turns out that she is in fact a demon.\\n\\nJane Eyre, by Charlotte Bront\\u00eb\\n\\nOne of the great episodes of transvestism in literature comes when Rochester togs himself up as a Gypsy woman to read the palms of the guests he has invited to Thornfield. Blanche Ingram, Jane's rival for his affections, gets uncomforting news, but Jane is told \\\"the cup of bliss\\\" is going to be offered to her.\\n\\nEast Lynne, by Mrs Henry Wood\\n\\nLady Isabel Vane loses her happy home and family when she conducts an adulterous affair with the utterly caddish Francis Levinson. Having learned the error of her ways, she returns to be governess to her own children, disguised by blue-lensed glasses, hair turned white from shock after a train crash and a scarred mouth.\\n\\nThe Mystery of Edwin Drood, by Charles Dickens\\n\\nDick Datchery arrives in the town of Cloisterham, apparently a detective in disguise (he wears a wig). He (or she?) keeps watch over John Jasper, choirmaster and secret drug addict. Drood has disappeared: is he disguised as Datchery? Or is it another character, investigating Drood's murder? Dickens did\"\n },\n {\n \"docid\": \"20048\",\n \"score\": 0.5056356191635132,\n \"snippet\": \"---\\ntitle: The Old Curiosity Shop - Wikipedia\\ndate: 2004-07-13\\n---\\nname: The Old Curiosity Shop\\nalt: Cover of Master Humphrey's Clock, in which the serial editions were published\\nauthor: Charles Dickens\\nillustrator: George CattermoleHablot Knight Browne (Phiz)Samuel WilliamsDaniel Maclise\\ncover_artist: George Cattermole\\ncountry: England\\nlanguage: English\\ngenre: Novel\\npublisher: Chapman & Hall London\\npublished: Serialised April 1840 \\u2013 February 1841, Old Curiosity Shop book format 1841\\nmedia_type: Print\\npreceded_by: Nicholas Nickleby\\nfollowed_by: Barnaby Rudge\\nwikisource: The Old Curiosity Shop\\n\\nThe Old Curiosity Shop is the fourth novel by English author Charles Dickens; being one of his two novels (the other being Barnaby Rudge) published along with short stories in his weekly serial Master Humphrey's Clock, from 1840 to 1841. It was so popular that New York readers reputedly stormed the wharf when the ship bearing the final instalment arrived in 1841.\\n\\nThe Old Curiosity Shop was printed in book form in 1841. Queen Victoria read the novel that year and found it \\\"very interesting and cleverly written\\\".\\n\\nThe plot follows the journey of Nell Trent and her grandfather, both residents of The Old Curiosity Shop in London, whose lives are thrown into disarray and destitution due to the machinations of an evil moneylender and the grandfather's addiction to gambling.\\n\\nSynopsis\\n\\nThe Old Curiosity Shop tells the story of Nell Trent, a beautiful and virtuous young girl of \\\"not quite fourteen\\\". An orphan, she lives with her maternal grandfather (whose name is never revealed) in his shop of odds and ends. Her grandfather loves her dearly, and Nell does not complain, but she lives a lonely existence with almost no friends her own age. Her only friend is Kit, an honest boy employed at the shop, whom she is teaching to write.\\n\\nSecretly obsessed with ensuring that Nell does not die in poverty as her parents did, her grandfather attempts to provide Nell with a good inheritance through gambling at cards. He keeps his nocturnal games a secret, but borrows heavily from the evil Daniel Quilp, a malicious, grotesquely de\"\n },\n {\n \"docid\": \"42402\",\n \"score\": 0.5011107325553894,\n \"snippet\": \"---\\ntitle: The Scarlet Pimpernel - Wikipedia\\nauthor: Authority control databases\\ndate: 2002-09-10\\n---\\nname: The Scarlet Pimpernel\\nauthor: Baroness Orczy\\ncountry: United Kingdom\\nlanguage: English\\ngenre: Historical fiction, adventure fiction\\nset_in: 1792, during the early stages of the French Revolution\\npublisher: Greening\\npub_date: 1905\\npages: 319\\npreceded_by: The First Sir Percy\\nfollowed_by: Sir Percy Leads the Band\\n\\nThe Scarlet Pimpernel is the first novel in a series of historical fiction by Baroness Orczy, published in 1905. It was written after her stage play of the same title (co-authored with her husband Montague Barstow) enjoyed a long run in London, having opened in Nottingham in 1903.\\n\\nThe novel is set during the Reign of Terror following the start of the French Revolution. The title is the nom de guerre of its hero and protagonist, a chivalrous Englishman who rescues aristocrats before they are sent to the guillotine. Sir Percy Blakeney leads a double life: apparently nothing more than a wealthy fop, but in reality, a formidable swordsman and a quick-thinking master of disguise and escape artist. The band of gentlemen who assist him are the only ones who know of his secret identity. He is known by his symbol, a simple flower, the scarlet pimpernel (Anagallis arvensis).\\n\\nOpening at the New Theatre in London's West End on 5 January 1905, the play became a favourite of British audiences, eventually playing more than 2,000 performances and becoming one of the most popular shows staged in London. Published after the success of the play, the novel was an immediate success, gaining Orczy a following of readers in Britain and the rest of the world. The stage play and subsequent novel, with their hero and villain, were so popular that they inspired a revival of classic villainy at the time.\\n\\nOrczy's premise of a daring hero who cultivates a secret identity disguised by a meek or ineffectual manner proved enduring. Zorro, Doctor Syn, the Shadow, the Spider, the Green Hornet, the Phantom, Superman and Batman followed within a few decades, and the trope remains a popular one in serial fiction today. Read by\"\n },\n {\n \"docid\": \"72751\",\n \"score\": 0.4939073622226715,\n \"snippet\": \"---\\ntitle: 5 Powerful Examples of Disguise As A Device In Fiction\\ndate: 2020-04-14\\n---\\nIn this blog, we show you five examples of disguise as a device in fiction. The examples from classic and modern literature explain how writers have used disguise in their stories to remarkable effect.\\n\\n5 Powerful Examples of Disguise As A Device In Fiction\\n\\nYou will discover:\\n\\n- How Shakespeare mastered disguise in his plays.\\n\\n- How Charlotte Bronte showed another side to her imposing hero Rochester.\\n\\n- How Virginia Woolf smashed gender stereotypes in her feminist masterpiece Orlando.\\n\\n- How Anne Fine helped a divorced father connect with his family in Madame Doubtfire.\\n\\n- How Anne Rice showed us human weakness and desire through immortal beings in Interview With The Vampire.\\n\\nWhy Use Disguise In Your Fiction?\\n\\nWe all know that disguise is a common storytelling device. It can be used to express a theme, illumine a character, or drive a plot. When a character in a short story, script, or novel disguises their identity, they could be seeking a truth or revealing a deceit.\\n\\nThe obfuscation always has a reason that fits in with the plot or theme of the story.\\n\\nAs a writer, you can use this sort of clever concealment or literary trickery to subvert stereotypes or play with readers' expectations. It is a powerful resource available to you as a writer.\\n\\nIt is also, I'm sure you will agree, a lot of fun to write.\\n\\nBrilliant Disguise\\n\\nWhile a superhero may wear a mask, he wears it as a concealment. A mask seems to 'seal' or close off an identity \\u2013 whereas a disguise is more about taking on or absorbing another identity, sometimes more than one identity, without abandoning a core character or personality. It's a form of character camouflage that is important to the story or a character's inner transformation.\\n\\n1. Mastering Disguise\\n\\nRecently, I was reading about Giuseppe Dosi. In the 1920s, he was possibly one of Italy's greatest detectives, so much so that he could have easily been a fictional creation.\\n\\nThis former actor was a master of disguises and he used disguise as a method to solve crime. In fact, his approach became known as detectival transformation. His personas included a femme fatal, priest, banker, and a war veteran with a 'bum' leg. All his identities had their own fake IDs and penmanship.\\n\\nShakespeare was, of course\"\n },\n {\n \"docid\": \"18321\",\n \"score\": 0.48267534375190735,\n \"snippet\": \"---\\ntitle: Bleak House - Wikipedia\\nauthor: Authority control databases\\ndate: 2004-01-25\\n---\\nname: Bleak House\\nimage_size: 200px\\nauthor: Charles Dickens\\nillustrator: Hablot Knight Browne (Phiz)\\ncover_artist: Hablot Knight Browne (Phiz)\\ncountry: England\\nlanguage: English\\ngenre: Novel\\npublisher: Bradbury & Evans\\npublished: Serialised 12 March 1852 \\u2013 12 September 1853, book form 12 September 1853\\npreceded_by: David Copperfield\\nfollowed_by: A Child's History of England\\n\\nBleak House is a novel by English author Charles Dickens, first published as a 20-episode serial between 12 March 1852 and 12 September 1853. The novel has many characters and several subplots, and is told partly by the novel's heroine, Esther Summerson, and partly by an omniscient narrator. At the centre of Bleak House is a long-running legal case in the Court of Chancery, Jarndyce and Jarndyce, which comes about because a testator has written several conflicting wills. In a preface to the 1853 first edition, Dickens said there were many actual precedents for his fictional case. One such was probably Thellusson v Woodford, in which a will read in 1797Constantine, Alison. The Restoration of Brodsworth Hall & Gardens, February 2007 historical address, at Tickhill & District Local History Society was contested and not determined until 1859. Though many in the legal profession criticised Dickens's satire as exaggerated, Bleak House helped support a judicial reform movement that culminated in the enactment of legal reform in the 1870s.\\n\\nSome scholars debate when Bleak House is set. The English legal historian Sir William Holdsworth sets the action in 1827; however, reference to preparation for the building of a railway in Chapter LV suggests the 1830s. A work of Gothic fiction depicting London as a murky city swathed in fog, Bleak House is credited with introducing urban fog to the novel, which would become a frequent characteristic of urban Gothic literature and film.Mighall, Robert (2007). \\\"Gothic\"\n }\n]", "invoking_actions": [{"name": "search", "arguments": {}, "id": "25b83571-eb9d-4413-8581-1ef74f8c696b", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.07896500000000001, "benchmark_cost": 0.0} +{"event": "action", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 4, "action": {"name": "search", "arguments": {"query": "novel encrypted document smuggle abroad cipher codes 19th century London historical"}, "id": "10fc0814-765c-4c01-b8b3-f41a0194c9ba", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.07896500000000001, "benchmark_cost": 0.0} +{"event": "observation", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 4, "observation": {"result": "[\n {\n \"docid\": \"38361\",\n \"score\": 0.5048168897628784,\n \"snippet\": \"---\\ntitle: The Secrets We Kept by Lara Prescott Reveals the Story Behind Doctor Zhivago\\nauthor: Author Alana White\\ndate: 2014-04-05\\n---\\nThe Secrets We Kept by Lara Prescott Reveals the Story Behind Doctor Zhivago\\n\\nOne of the most highly anticipated historical novels to hit shelves this fall was Lara Prescott's The Secrets We Kept (Hutchinson, September 2019). Selected for Reese Witherspoon's Hello Sunshine Book Club for September 2019, the novel was also nominated as a Best Debut Novel in the 11th Annual Goodreads Choice Awards. The film and TV rights have already been optioned.\\n\\nPrescott's novel reveals the intriguing true-life espionage plot behind Boris Pasternak's beloved novel, Doctor Zhivago. Set mainly in the US and the USSR in the 1950s, the two main protagonists in The Secrets We Kept are Sally Forrester and Irina Drozdov. Both women work as typists in the male-dominated CIA, but they are actually spies. Sally, the veteran spy, trains Irina and their mission is to first smuggle Boris Pasternak's Doctor Zhivago out of the USSR in order to publish it and then they must disseminate banned copies of Doctor Zhivago to Soviet citizens. The goal of this mission is to make people in the USSR question their government and expose the realities of communist life. The other half of The Secrets We Kept is told from the perspective of Boris Pasternak and his lover Olga Ivinskaya, the inspiration behind the character of Lara in Doctor Zhivago.\\n\\nWhile Sally and Irina are fictional characters, the covert spycraft behind Doctor Zhivago is entirely factual. The true-life back story of Doctor Zhivago first came to light in 2014, when 130 documents and declassified CIA files were released, exposing the CIA \\\"Books Program\\\" of the Cold War era. Up until that point, not many people knew about the story behind Doctor Zhivago and that the CIA viewed books as weapons during the Cold War, utilizing banned books to fight the Soviets and their propaganda.[1]\\n\\nLara Prescott first learned about the history behind Doctor Zhivago in 2014 when her father sent her a Washington Post article about these declassified CIA documents. Intrigued and wanting to know more, Prescott started to do some digging of her\"\n },\n {\n \"docid\": \"93624\",\n \"score\": 0.49482470750808716,\n \"snippet\": \"---\\ntitle: THE AGONY COLUMN OF THE \\\"TIMES\\\" 1800-1870\\ndate: 2019-01-01\\n---\\n*** START OF THE PROJECT GUTENBERG EBOOK 54658 ***\\n\\nPlease see the Transcriber's Notes at the end of the text.\\n\\nOF THE \\\"TIMES\\\"\\n\\n1800-1870\\n\\nWITH AN INTRODUCTION\\n\\nEDITED BY ALICE CLAY\\n\\nLondon\\n\\nCHATTO AND WINDUS, PICCADILLY\\n\\n1881\\n\\n[All rights reserved]\\n\\nPRINTED BY WILLIAM CLOWES AND SONS, LIMITED, LONDON AND BECCLES.\\n\\nThe contents of the little volume now presented to the public have been taken from the second column (commonly called the \\\"Agony Column\\\") of the Times newspaper, from the commencement of the present century to the end of the year 1870.\\n\\nReaders of newspapers (more especially of the Times) cannot fail to be struck by the mysterious communications which daily appear, and I venture to hope my selection of some of the most remarkable may interest those who peruse these pages.\\n\\nMost of the advertisements selected show a curious phase of life, interesting to an observer of human existence and human eccentricities. They are veiled in an air of mystery, with a view of blinding the general public, but at the same time give a clue unmistakable to those for whom they were intended.\\n\\nAt the early period of 1800 the \\\"Agony Column\\\" seems to have been the chief medium for matrimonial advertisements; but, unfortunately, we are left considerably in the dark, and our curiosity as to whether the young nobleman (in advertisement[vi] No. 2) eventually married the unknown \\\"Catholic widow\\\" is not gratified; but we do learn something, namely, that love at first sight was not so rare in those days as it is supposed to be in the present unromantic age.\\n\\nThere is little doubt that lovers separated by unfortunate circumstances, or by angry parents, as well as bachelors meditating matrimony, have found in the \\\"Agony Column\\\" a safe means of secret correspondence. With what despair did \\\"One-winged Dove\\\" (advertisement No. 214) beseech her lover, the \\\"Crane,\\\" to return to her! Sorely must her patience have been tried as she scanned the paper in vain day\"\n },\n {\n \"docid\": \"29367\",\n \"score\": 0.49336209893226624,\n \"snippet\": \"---\\ntitle: Historical Fictionistas discussion\\nauthor: Author add link photo\\ndate: 2012-10-11\\n---\\nHistorical Fictionistas discussion\\n\\nRecommendations?\\n\\n>\\n\\nCrime in 19th Century London?\\n\\ndate\\n\\nnewest \\u00bb\\n\\nmessage 1:\\n\\nby\\n\\nAllison\\n\\n(new)\\n\\nOct 11, 2012 07:57AM\\n\\nHey you guys...I would love some recommendations on books set in 19th century London revolving around crime (other than Jack the Ripper). Fiction and non-fiction would both be great!\\n\\nreply\\n\\n|\\n\\nflag\\n\\nAnything by Anne Perry in the Inspector Monk and the Thomas Pitt series. They start with The Face of a Stranger and The Cater Street Hangman, respectively. Monk is 1860s and Pitt somewhat later.\\n\\nDickens, of course. But no one does 19th-century London crime quite like Perry.\\n\\nSome of Tasha Alexander's Lady Emily Ashton novels, starting with And Only to Deceive, are also set in London, but they are more oriented toward elite crime than the nitty-gritty grim reality of the Isle of Dogs.\\n\\nDickens, of course. But no one does 19th-century London crime quite like Perry.\\n\\nSome of Tasha Alexander's Lady Emily Ashton novels, starting with And Only to Deceive, are also set in London, but they are more oriented toward elite crime than the nitty-gritty grim reality of the Isle of Dogs.\\n\\nDavid Liss's Benjamin Weaver series is my favorite. It begins with A Conspiracy of Paper, a HF murder/mystery/thriller novel about a financial crisis in 19th century London. You'll love Benjamin and learn a lot about economics (without being bored to tears) because Liss is such a master at inserting historical details in creative ways. David Liss\\n\\nI love historical mysteries, so here's a few of my favorites set in London in the 19th century:\\n\\nThe Yard\\n\\nThe Solitary House\\n\\nA gritty series:\\n\\nWill Thomas, the first title isSome Danger Involved\\n\\nA couple of aristocratic series:\\n\\nC.S. Harris, Regency era with the first in the series being What Angels Fear\\n\\nCharles Finch,A Beautiful Blue Death is first in the series\\n\\nEarly forensics:\\n\\nDevoured by D.E. Meredith\\n\\nEnjoy!\\n\\nThe Yard\\n\\nThe Solitary House\\n\\nA gritty series:\\n\\nWill Thomas, the first title isSome Danger Involved\\n\\nA couple of arist\"\n },\n {\n \"docid\": \"1891\",\n \"score\": 0.4854259192943573,\n \"snippet\": \"---\\ntitle: Kryptos - Wikipedia\\ndate: 2004-02-06\\n---\\ntitle: Kryptos\\nartist: Jim Sanborn\\nyear: 1990\\ndimensions: 11\\u201312 feet \\u00d7 20 feet\\nmuseum: George Bush Center for Intelligence\\ncity: Langley, Virginia\\ncoordinates: 38.95227 -77.14573 type:landmark_region:US-VA dms inline, title\\n\\nKryptos is a sculpture by the American artist Jim Sanborn located on the grounds of the Central Intelligence Agency (CIA) headquarters, the George Bush Center for Intelligence in Langley, Virginia.\\n\\nSince its dedication on November 3, 1990, there has been much speculation about the meaning of the four encrypted messages it bears. Of these four messages, the first three have been solved, while the fourth message remains one of the most famous unsolved codes in the world. It is said that a fifth message will reveal itself after the first four are solved. The sculpture continues to be of interest to cryptanalysts, both amateur and professional, who are attempting to decode the fourth passage. The artist has so far given four clues to this passage.\\n\\nDescription\\n\\nThe sculpture comprises four large copper plates with other elements consisting of water, wood, plants, red and green granite, white quartz, and petrified wood. The most prominent feature of the entire piece is a large vertical S-shaped copper screen resembling a scroll or a piece of paper emerging from a computer printer, half of which consists of encrypted text, that is located in the northwest corner of the New Headquarters Building courtyard, outside of the agency's cafeteria. The characters are all found within the 26 letters of the Latin alphabet, along with question marks, and are cut out of the copper plates. The main sculpture contains four separate enigmatic messages, three of which have been deciphered.\\n\\nIn addition to the main part of the sculpture, Sanborn also placed other pieces of art on the CIA grounds, such as several large granite slabs with sandwiched copper sheets outside the entrance to the New Headquarters Building. Several Morse code messages are found on these copper sheets, and one of the stone slabs has an engraving of a compass rose pointing to a lodestone. The ciphers' increasing \\\"complexity\\\" through the entrance into the courtyard is intended to be as if it \\\"were a fossil\\\". Other elements of San\"\n },\n {\n \"docid\": \"81929\",\n \"score\": 0.4801541864871979,\n \"snippet\": \"---\\ntitle: 12 Captivating Historical Fiction Books Set in London\\u2014Romance, Mystery and Drama Await!\\ndate: 2025-02-20\\n---\\n12 Captivating Historical Fiction Books Set in London\\u2014Romance, Mystery and Drama Await!\\n\\nFrom intrigue to passionate romance and adventure, these novels are your ticket to a bygone era!\\n\\nEvery major city in the world brims with rich history, storied landmarks and vibrant culture\\u2014but there's nothing quite like historical London. In fact, London's history spans nearly two millennia from Roman times to medieval times, Georgian times and beyond, making it home to some of the most iconic places in the world. Whether you're enthralled by the grandeur of Buckingham Palace or the Houses of Parliament or you can't get enough of certain time periods like the Victorian era or WWII, London truly provides a perfect backdrop for historical fiction novels.\\n\\nSo if you're looking to escape into the past through a London lens, you're in luck! Here, we gathered up 12 historical fiction books set in London\\u2014across bygone eras\\u2014that deliver dynamic storylines (many based on real-life events), memorable characters, high-stakes danger and passionate war-torn love stories.\\n\\nDoes stepping foot into Kew Gardens in 1916 London and embarking on a quest for women's suffrage sound fascinating? Pick up The Kew Garden Girls by Posy Lovell. Lovell's book is inspired by true events and bestselling author Natasha Lester called it \\\"an absolutely charming story about the strength and beauty of female friendship.\\\" If you're more in the mood to experience 1930s London alongside five of the most legendary female writers\\u2014including Agatha Christie\\u2014then pick up The Queens of Crime by Marie Benedict. This historical mystery is part whodunit, part adventure and fully immersive!\\n\\nContinue scrolling for all of our compelling, must-read book recommendations. Cheerio!\\n\\n'The Queens of Crime' by Marie Benedict\\n\\nDanger and intrigue come alive in this tale set in 1930s London and inspired by a true story. The five greatest female crime writers, including Agatha Christie and her legendary rival Dorothy Sayers, form a secret society with one goal: to show their refusal to be treated poorly by their male counterparts. To prove it, they'll solve the actual murder of May Daniels. But the culprit targets Sayers next and threatens to expose a dark secret.\\n\\nWhat readers are saying: \\\"This book is a captivating blend of\"\n }\n]", "invoking_actions": [{"name": "search", "arguments": {}, "id": "10fc0814-765c-4c01-b8b3-f41a0194c9ba", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.07896500000000001, "benchmark_cost": 0.0} +{"event": "action", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 5, "action": {"name": "search", "arguments": {"query": "historical fiction woman disguise businessperson accused theft document schemer storm rescue"}, "id": "4a1ca3f6-3b42-43d8-bb94-1583246b1cca", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.15477000000000002, "benchmark_cost": 0.0} +{"event": "observation", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 5, "observation": {"result": "[\n {\n \"docid\": \"51328\",\n \"score\": 0.5671062469482422,\n \"snippet\": \"---\\ntitle: Celebrating History's Bad-Ass Women: PW Talks with Greer Macallister\\nauthor: Claire Kirch\\ndate: 2017-02-28\\n---\\nGreer Macallister's historical novel Girl in Disguise (Sourcebooks) is a rollicking mashup of the real and imagined exploits of Kate Warne, the first female operative employed by the famed Pinkerton Detective Agency.\\n\\nHow did you come to write Girl in Disguise?\\n\\nWhen I first heard Kate's story a few years ago, I was floored. This woman was such a pioneer. She was a female detective in the 1850s, when it was completely unheard-of, and she was so good at it that she was assigned by Allan Pinkerton to run her own division. She helped save Abraham Lincoln's life as he made his way to his inauguration. She was an undercover spy for the Union during the Civil War. Somebody needed to get her story out there, and I figured it might as well be me!\\n\\nYou mention in your author's note that very little is known about Kate Warne; there aren't even verified photos of her--partially because she was a spy and also because Pinkerton Agency records were lost during the Chicago Fire of 1871. How much of her story is based on historical sources and how much did you have to imagine?\\n\\nThose gaps in the historical record made Kate the perfect subject for historical fiction--a little history and a lot of fiction. We know the facts on a handful of cases she worked, like the Adams Express case and one where she posed as a fortune-teller to catch a poisoner. Her role in thwarting the Lincoln assassination attempt in Baltimore is documented. But a lot of the rest is just open space, so I got to choose how to fill it in.\\n\\nWere the lives of Warne's male colleagues--including Allan Pinkerton--similarly shrouded in mystery?\\n\\nNot to the same degree, although some operatives' lives were better-documented than others. Pinkerton himself wrote and published a lot about his own prowess, so with him it's the opposite--tons of information, but not all reliable. For the cast of characters around Kate, I drew on some information about her real colleagues at the time, but mostly I combined and synthesized and created. It's all about balancing story and history to make the best possible experience for the reader.\\n\\nKate Warne is a mature woman, a widow, when she\"\n },\n {\n \"docid\": \"99787\",\n \"score\": 0.5656360983848572,\n \"snippet\": \"---\\ntitle: Girl in Disguise Hardcover \\u2013 March 21, 2017\\nauthor: Greer Macallister\\ndate: 2017-03-21\\n---\\n$25.99$25.99\\n\\n$7.59 delivery\\n\\nShips from: Amazon.com Sold by: Amazon.com\\n\\n$9.75$9.75\\n\\nDelivery Thursday, June 19\\n\\nShips from: Amazon Sold by: t.rey treasures\\n\\nReturn this item for free\\n\\nFree returns are available for the shipping address you chose. You can return the item for any reason in new and unused condition: no return shipping charges.\\n\\nLearn more about free returns.- Go to your orders and start the return\\n\\n- Select your preferred free shipping option\\n\\n- Drop off and leave!\\n\\nSorry, there was a problem.\\n\\nThere was an error retrieving your Wish Lists. Please try again.Sorry, there was a problem.\\n\\nList unavailable.Download the free Kindle app and start reading Kindle books instantly on your smartphone, tablet, or computer - no Kindle device required.\\n\\nRead instantly on your browser with Kindle for Web.\\n\\nUsing your mobile phone camera - scan the code below and download the Kindle app.\\n\\nAudible sample\\n\\nFollow the author\\n\\nOK\\n\\nGirl in Disguise Hardcover \\u2013 March 21, 2017\\n\\nPurchase options and add-ons\\n\\nFrom the USA Today Bestselling author of The Magician's Lie\\n\\n\\\"Macallister is becoming a leading voice in strong, female-driven historical fiction. Exciting, frightening, and unspeakably moving...\\\"\\u2015Erika Robuck, bestselling author of Hemingways's Girl\\n\\nFor the first daring female Pinkerton detective, respect is hard to come by, but danger and spies are everywhere.\\n\\nIn the tumultuous years of the Civil War, the streets of Chicago offer a woman mostly danger and ruin\\u2015unless that woman is Kate Warne. As an undercover Pinkerton detective, Kate is able to infiltrate the seedy side of the city in disguises that her fellow spies just can't manage. She's a seductress, an exotic foreign medium, a rich train passenger\\u2015all depending on the day and the robber, thief, or murderer she's been assigned to nab.\\n\\nBut is it only her detective work that makes her a daring spy and a clever liar? Or is the real disguise the good girl she always thought she was? As the Civil War marches closer, Kate takes on her most pressing job\"\n },\n {\n \"docid\": \"53914\",\n \"score\": 0.5568761825561523,\n \"snippet\": \"---\\ntitle: Search This Blog\\nauthor: Sarah Johnson\\ndate: 2014-08-29\\n---\\nThese novels aren't fanciful in premise. In actuality, there were many women who disguised their sex and fought in the US Civil War and in earlier battles, but recognition of and pride in their accomplishments has often been long in coming. These works of fiction, some of which are based on the lives of specific historical women, help to spread word about their deeds and heroism in the popular consciousness.\\n\\nA young woman who had been fighting for the Union in disguise has to hide her loyalties after she's wounded and gets trapped behind Confederate lines. RiverNorth, June 2014.\\n\\nA rare novel that looks at this scenario from the Confederate side, as two Southern sisters enlist in the Confederate army as new recruits, their secret known only to one another. The co-authors are sisters as well. Houghton Mifflin Harcourt, March 2015.\\n\\nHer husband being too weak to go to war, an Indiana farm wife dons male garb and marches off to fight for the Union. I'll have a review of this new literary novel shortly. Little Brown, September 2014.\\n\\nBelieving her place is with her newly-wed husband, Rosetta Wakefield secretly follows him into the Union ranks, fighting alongside him and proving her worth in battle. Loosely based on the life of Sarah Rosetta Wakeman. Crown, January 2014; out in paperback in September, with a beautiful new cover.\\n\\nThis novel about Massachusetts heroine Deborah Sampson shows her external and internal transformations during her service in the Revolutionary War. See my review of Revolutionary as well as Alex Myers' guest post here. Simon & Schuster, January 2014.\\n\\nFrom the author of the 4-book Far Western Civil War series comes a new novel about Emma Edmonds, who signed on with the 2nd Michigan Volunteers under the name Frank Thompson. BookView Cafe, April 2014.\\n\\nOne of my secondary characters in my Civil War novels Promise & Honor and Honor & Glory disguised herself. I had a reviewer on Amazon say that she liked the character but found it unbelievable that a woman could succeed in hiding herself that way.\\n\\nReplyDeleteI would love to write a novel on an unknown female soldier that I found in an article from an 1863 Missouri Democrat. I've blogged about her on a couple of\"\n },\n {\n \"docid\": \"41618\",\n \"score\": 0.5525739192962646,\n \"snippet\": \"---\\ntitle: Women Looking Away in Period Costume\\ndate: 2021-08-10\\n---\\nWomen Looking Away in Period Costume\\n\\nHistorical fiction with one little thing in common.\\n\\nAugust 10, 2021\\n\\nWe're going to let you in on a librarian secret: sometimes we judge a book by its cover. The fastest way to find historical fiction is to look for a woman in period costume looking (or walking) away!\\n\\nThe Address\\n\\nFiona Davis\\n\\nInterior designer Bailey Camdenis leaps at a chance to renovate her heiress cousin's lavish apartment at The Dakota, and learns the scandalous history of a distant ancestor's connection to the murder of the building's architect a century earlier.\\n\\nThe Alice Network\\n\\nKate Quinn\\n\\nIn 1947, pregnant Charlie St. Clair, an American college girl banished from her family, arrives in London to find out what happened to her beloved cousin Rose, who disappeared in Nazi-occupied France during the war, and meets a former spy who, torn apart by betrayal, agrees to help her on her mission.\\n\\nAll the Flowers in Paris\\n\\nSarah Jio\\n\\nA tale told from alternating viewpoints follows the experiences of a Parisian woman who awakens with no memory of her past before discovering a mysterious cache of letters written by a young woman of Jewish ancestry during the Nazi occupation.\\n\\nThe Atomic City Girls\\n\\nJanet Beard\\n\\nWorking in support of the war effort, June Walker begins an affair with a young Jewish physicist in hopes of uncovering what the government's end goal is, until the bombing of Hiroshima reveals the truth about what they are doing.\\n\\nAtomic Love\\n\\nJennie Fields\\n\\nRecruited by the FBI to spy on her former lover, a guilt-riddled Manhattan Project physicist becomes torn between lingering feelings for her ex and her growing attraction to a special agent, a former prisoner of war.\\n\\nBand of Sisters\\n\\nLauren Willig\\n\\nEschewed by her wealthy graduated classmates, a former scholarship student reluctantly volunteers to help World War I French civilians before finding herself surrounded by desperate families in villages decimated by German bombs\\n\\nThe Book of Lost Names\\n\\nKristin Harmel\\n\\nEscaping from Paris in 1942 after the arrest of her father, a Polish Jew, a graduate student finds refuge in a small mountain town, where she forges identity documents to help hundreds of Jewish children flee the Nazis.\\n\\nChurchill's Secret Messenger\\n\\nAlan Hlad\\n\\nRecruited from Churchill's typing pool to become an undercover spy\"\n },\n {\n \"docid\": \"95934\",\n \"score\": 0.550140380859375,\n \"snippet\": \"---\\ntitle: What's the Name of That Book??? discussion\\nauthor: Author add link photo\\ndate: 2017-09-06\\n---\\nWhat's the Name of That Book??? discussion\\n\\n\\u25ba Suggest books for me\\n\\n>\\n\\nWoman disguised as a man\\n\\nYou might try looking through the whole \\\"Suggest books for me\\\" folder. There are multiple threads with this theme.\\n\\nThe Masqueraders this one has it all--sister disguised as a man and brother disguised as a woman. What more can anyone ask, right? It is also by Georgette Heyer which means it's almost guaranteed to be good.\\n\\nUnmasking Miss Appleby - this Regency fantasy romance has the heroine using actual magic to turn herself into a man as opposed to dressing as one, which paradoxically makes the charade a lot more believable.\\n\\nThe protagonist of The Third Witch, a young girl, disguises herself as a serving boy to infiltrate the castle of her enemy, Macbeth (of the Shakespeare play).\\n\\nToward the end of Dating Hamlet: Ophelia's Story, Ophelia dresses as a boy to reenter Elsinore after faking her death.\\n\\nLove Disguised also features a girl dressing up as a man.\\n\\nToward the end of Dating Hamlet: Ophelia's Story, Ophelia dresses as a boy to reenter Elsinore after faking her death.\\n\\nLove Disguised also features a girl dressing up as a man.\\n\\nRidiculous by D.L. Carter. Regency era. Woman dresses as a man to save her family from financial ruin.\\n\\nThe Horse and His Boy by C. S. Lewis has someone doing this, and she's an important character, although not the protagonist. The Horse and His Boy\\n\\nSisters of Shiloh by Kathy Hepinstall features 2 sisters who disguise themselves as men and join the Confederate Army\\u2014one seeking vengeance on the battlefield, the other finding love.\\n\\nI\\n\\nI\\n\\nPaladin by Sally Slater.\\n\\nthe h is not your typical maria clara. she enjoys sword work and is good at it. in order to join a quest, she has to pretend to be a boy, and she is on par with other males in terms of one on one fights. the H is a team mate on this quest, one of the 1st to diacover her secret, he is scarred emotionally.\\n\\nthe h is not your typical maria clara. she enjoys sword work and\"\n }\n]", "invoking_actions": [{"name": "search", "arguments": {}, "id": "4a1ca3f6-3b42-43d8-bb94-1583246b1cca", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.15477000000000002, "benchmark_cost": 0.0} +{"event": "action", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 6, "action": {"name": "search", "arguments": {"query": "\"Girl in Disguise\" Kate Warne businessman document theft sea storm rescue"}, "id": "6c30154a-9867-4038-b80b-69091e75d611", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.24781500000000004, "benchmark_cost": 0.0} +{"event": "observation", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 6, "observation": {"result": "[\n {\n \"docid\": \"51328\",\n \"score\": 0.7458477020263672,\n \"snippet\": \"---\\ntitle: Celebrating History's Bad-Ass Women: PW Talks with Greer Macallister\\nauthor: Claire Kirch\\ndate: 2017-02-28\\n---\\nGreer Macallister's historical novel Girl in Disguise (Sourcebooks) is a rollicking mashup of the real and imagined exploits of Kate Warne, the first female operative employed by the famed Pinkerton Detective Agency.\\n\\nHow did you come to write Girl in Disguise?\\n\\nWhen I first heard Kate's story a few years ago, I was floored. This woman was such a pioneer. She was a female detective in the 1850s, when it was completely unheard-of, and she was so good at it that she was assigned by Allan Pinkerton to run her own division. She helped save Abraham Lincoln's life as he made his way to his inauguration. She was an undercover spy for the Union during the Civil War. Somebody needed to get her story out there, and I figured it might as well be me!\\n\\nYou mention in your author's note that very little is known about Kate Warne; there aren't even verified photos of her--partially because she was a spy and also because Pinkerton Agency records were lost during the Chicago Fire of 1871. How much of her story is based on historical sources and how much did you have to imagine?\\n\\nThose gaps in the historical record made Kate the perfect subject for historical fiction--a little history and a lot of fiction. We know the facts on a handful of cases she worked, like the Adams Express case and one where she posed as a fortune-teller to catch a poisoner. Her role in thwarting the Lincoln assassination attempt in Baltimore is documented. But a lot of the rest is just open space, so I got to choose how to fill it in.\\n\\nWere the lives of Warne's male colleagues--including Allan Pinkerton--similarly shrouded in mystery?\\n\\nNot to the same degree, although some operatives' lives were better-documented than others. Pinkerton himself wrote and published a lot about his own prowess, so with him it's the opposite--tons of information, but not all reliable. For the cast of characters around Kate, I drew on some information about her real colleagues at the time, but mostly I combined and synthesized and created. It's all about balancing story and history to make the best possible experience for the reader.\\n\\nKate Warne is a mature woman, a widow, when she\"\n },\n {\n \"docid\": \"99787\",\n \"score\": 0.7182636857032776,\n \"snippet\": \"---\\ntitle: Girl in Disguise Hardcover \\u2013 March 21, 2017\\nauthor: Greer Macallister\\ndate: 2017-03-21\\n---\\n$25.99$25.99\\n\\n$7.59 delivery\\n\\nShips from: Amazon.com Sold by: Amazon.com\\n\\n$9.75$9.75\\n\\nDelivery Thursday, June 19\\n\\nShips from: Amazon Sold by: t.rey treasures\\n\\nReturn this item for free\\n\\nFree returns are available for the shipping address you chose. You can return the item for any reason in new and unused condition: no return shipping charges.\\n\\nLearn more about free returns.- Go to your orders and start the return\\n\\n- Select your preferred free shipping option\\n\\n- Drop off and leave!\\n\\nSorry, there was a problem.\\n\\nThere was an error retrieving your Wish Lists. Please try again.Sorry, there was a problem.\\n\\nList unavailable.Download the free Kindle app and start reading Kindle books instantly on your smartphone, tablet, or computer - no Kindle device required.\\n\\nRead instantly on your browser with Kindle for Web.\\n\\nUsing your mobile phone camera - scan the code below and download the Kindle app.\\n\\nAudible sample\\n\\nFollow the author\\n\\nOK\\n\\nGirl in Disguise Hardcover \\u2013 March 21, 2017\\n\\nPurchase options and add-ons\\n\\nFrom the USA Today Bestselling author of The Magician's Lie\\n\\n\\\"Macallister is becoming a leading voice in strong, female-driven historical fiction. Exciting, frightening, and unspeakably moving...\\\"\\u2015Erika Robuck, bestselling author of Hemingways's Girl\\n\\nFor the first daring female Pinkerton detective, respect is hard to come by, but danger and spies are everywhere.\\n\\nIn the tumultuous years of the Civil War, the streets of Chicago offer a woman mostly danger and ruin\\u2015unless that woman is Kate Warne. As an undercover Pinkerton detective, Kate is able to infiltrate the seedy side of the city in disguises that her fellow spies just can't manage. She's a seductress, an exotic foreign medium, a rich train passenger\\u2015all depending on the day and the robber, thief, or murderer she's been assigned to nab.\\n\\nBut is it only her detective work that makes her a daring spy and a clever liar? Or is the real disguise the good girl she always thought she was? As the Civil War marches closer, Kate takes on her most pressing job\"\n },\n {\n \"docid\": \"45315\",\n \"score\": 0.6723828911781311,\n \"snippet\": \"---\\ntitle: America's First Female Detective Was a Chicago 'Girl in Disguise'\\nauthor: Karen Abbott\\ndate: 2018-05-21\\n---\\nGreer Macallister's most recent novel, Girl In Disguise, based on the real-life first woman detective in the U.S., is now out in paperback. Girl In Disguise was inspired by Kate Warne, who was hired by Allan Pinkerton in 1856 Chicago to solve cases and fight crime.\\n\\nI recently sat down with Greer to discuss the facts and fiction behind her version of this groundbreaking detective, how she researched Kate and her contemporaries, and some of her recent reads.\\n\\nKaren Abbott\\n\\nWhat can you tell us about your research process? There is not much information about Kate Warne, and certainly not much primary source material. Did you find anything surprising that shed light on her character, or is she mostly a figment of your imagination?\\n\\nGreer Macallister\\n\\nI tell people that this particular work of historical fiction is a little history and a lot of fiction. You're right that provable historical information on Kate is hard to come by. If I were a biographer I would have thrown up my hands in defeat after I visited the Pinkerton Agency archives in the Library of Congress and it didn't even take me a full day to locate and review every document that named Kate. Instead I decided that for a historical novelist, those gaps in the record were invitations, and I'd unwittingly found just the right subject for a novel-length fictional treatment. A nonfiction account of Kate that stuck to known facts would be an article at most \\u2014 and much of that would be\\n\\ndrawn from Allan Pinkerton's books, stretching the definition of \\\"known facts,\\\" given that he's not always reliable either.\\n\\nI tried to use my imagination to interpret and extrapolate from what we know instead of substituting for it. We know she didn't have children, but not how she felt about it, so I had to imagine what her reasoning was behind that choice, or whether it was a choice at all. She was likely a widow \\u2014 Pinkerton said she was \\u2014 so I spun a story around that too. She must have been a talented actress with a gift for mimicking accents, given that she was able to blend in with native Southerners when she went undercover in their midst as a woman from Alabama. From all the \\\"musts\\\" and \\\"likelies,\\\" I wove my version\"\n },\n {\n \"docid\": \"53914\",\n \"score\": 0.5337740182876587,\n \"snippet\": \"---\\ntitle: Search This Blog\\nauthor: Sarah Johnson\\ndate: 2014-08-29\\n---\\nThese novels aren't fanciful in premise. In actuality, there were many women who disguised their sex and fought in the US Civil War and in earlier battles, but recognition of and pride in their accomplishments has often been long in coming. These works of fiction, some of which are based on the lives of specific historical women, help to spread word about their deeds and heroism in the popular consciousness.\\n\\nA young woman who had been fighting for the Union in disguise has to hide her loyalties after she's wounded and gets trapped behind Confederate lines. RiverNorth, June 2014.\\n\\nA rare novel that looks at this scenario from the Confederate side, as two Southern sisters enlist in the Confederate army as new recruits, their secret known only to one another. The co-authors are sisters as well. Houghton Mifflin Harcourt, March 2015.\\n\\nHer husband being too weak to go to war, an Indiana farm wife dons male garb and marches off to fight for the Union. I'll have a review of this new literary novel shortly. Little Brown, September 2014.\\n\\nBelieving her place is with her newly-wed husband, Rosetta Wakefield secretly follows him into the Union ranks, fighting alongside him and proving her worth in battle. Loosely based on the life of Sarah Rosetta Wakeman. Crown, January 2014; out in paperback in September, with a beautiful new cover.\\n\\nThis novel about Massachusetts heroine Deborah Sampson shows her external and internal transformations during her service in the Revolutionary War. See my review of Revolutionary as well as Alex Myers' guest post here. Simon & Schuster, January 2014.\\n\\nFrom the author of the 4-book Far Western Civil War series comes a new novel about Emma Edmonds, who signed on with the 2nd Michigan Volunteers under the name Frank Thompson. BookView Cafe, April 2014.\\n\\nOne of my secondary characters in my Civil War novels Promise & Honor and Honor & Glory disguised herself. I had a reviewer on Amazon say that she liked the character but found it unbelievable that a woman could succeed in hiding herself that way.\\n\\nReplyDeleteI would love to write a novel on an unknown female soldier that I found in an article from an 1863 Missouri Democrat. I've blogged about her on a couple of\"\n },\n {\n \"docid\": \"78562\",\n \"score\": 0.5296750068664551,\n \"snippet\": \"---\\ntitle: Disguised or living as a man (Sapphic Reading Challenge #33)\\ndate: 2021-08-26\\n---\\nThis week's category of the Sapphic Reading Challenge features books about main characters who are either women who disguise themselves as men out of necessity or genderqueer people who were assigned female at birth but might identify as nonbinary or as trans men (even though the book might not use those terms since most books on this list are historical romances).\\n\\nRead up on the rules of the Sapphic Reading Challenge\\n\\nIf you are only discovering the Sapphic Reading Challenge now, the good news is you can still join! It's a year-long challenge, so it runs until December 31, 2021. Read up on the rules of the Sapphic Reading Challenge and download your PDF so you can keep track of the books you read this year.\\n\\n15 sapphic books featuring main characters who are disguised or living as men\\n\\nI put together a list of 15 sapphic books featuring a character who lives their life as a man at least for major parts of the book. I hope you'll find at least one you'll enjoy.\\n\\nBackwards to Oregon by Jae\\n\\nLuke Hamilton has always been sure that she'd never marry. She accepted that she would spend her life alone when she chose to live her life disguised as a man.\\n\\nAfter working in a brothel for three years, Nora Macauley has lost all illusions about love. She no longer hopes for a man who will sweep her off her feet and take her away to begin a new, respectable life.\\n\\nBut now they find themselves married and on the way to Oregon in a covered wagon, with two thousand miles ahead of them.\\n\\nAvailable at:\\n\\nCrossing the Wide Forever by Missouri Vaun\\n\\nCody Walsh leaves Arkansas for California. Lured by stories of opportunity, even for women, Cody disguises herself as a man and prepares for the arduous journey west.\\n\\nLillie Ellis leaves New York to accept a post as a schoolteacher on the frontier near a small homestead she just inherited from her uncle. Lillie's ultimate desire is to become a painter, and she hopes the Kansas frontier will offer her the freedom to follow that dream. In the nineteenth century, a young woman has few options in the East that don't revolve around marriage and motherhood. Lillie is interested in neither.\\n\\nCody rescues Lill\"\n }\n]", "invoking_actions": [{"name": "search", "arguments": {}, "id": "6c30154a-9867-4038-b80b-69091e75d611", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.24781500000000004, "benchmark_cost": 0.0} +{"event": "action", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 7, "action": {"name": "search", "arguments": {"query": "19th century London novel woman man disguise businessman disastrous deal stolen document accusation"}, "id": "e597d7c7-bfa3-4158-8d45-bf01379bba4d", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.358665, "benchmark_cost": 0.0} +{"event": "observation", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 7, "observation": {"result": "[\n {\n \"docid\": \"19305\",\n \"score\": 0.6351392865180969,\n \"snippet\": \"---\\ntitle: Ten of the best disguises in literature\\nauthor: John Mullan\\ndate: 2010-09-25\\n---\\nThe Odyssey, by Homer\\n\\nOdysseus arrives back at his island of Ithaca disguised as a beggar. He is recognised only by his old dog Argus (animals always see through disguises), which dies of joy on the spot. In his disguise, our hero is able to see who has been loyal to him and who has not.\\n\\nMeasure for Measure, by William Shakespeare\\n\\nThe Duke who governs Vienna wants to see what his underlings will get up to in his absence. So he asks his friend Friar Thomas for some monkish garb: \\\"Supply me with the habit and instruct me / How I may formally in person bear me / Like a true friar\\\". It works, and not even his most devoted courtiers recognise him until he finally unveils himself.\\n\\nThe Monk, by Matthew Lewis\\n\\nAnother monkish disguise. Sexy young Matilda lusts after Father Ambrosio, the most pious monk in Madrid. So she dresses up as a young novice monk and finds her way into the monastery. In her cell she reveals herself to Ambrosio, who cannot resist her charms. It turns out that she is in fact a demon.\\n\\nJane Eyre, by Charlotte Bront\\u00eb\\n\\nOne of the great episodes of transvestism in literature comes when Rochester togs himself up as a Gypsy woman to read the palms of the guests he has invited to Thornfield. Blanche Ingram, Jane's rival for his affections, gets uncomforting news, but Jane is told \\\"the cup of bliss\\\" is going to be offered to her.\\n\\nEast Lynne, by Mrs Henry Wood\\n\\nLady Isabel Vane loses her happy home and family when she conducts an adulterous affair with the utterly caddish Francis Levinson. Having learned the error of her ways, she returns to be governess to her own children, disguised by blue-lensed glasses, hair turned white from shock after a train crash and a scarred mouth.\\n\\nThe Mystery of Edwin Drood, by Charles Dickens\\n\\nDick Datchery arrives in the town of Cloisterham, apparently a detective in disguise (he wears a wig). He (or she?) keeps watch over John Jasper, choirmaster and secret drug addict. Drood has disappeared: is he disguised as Datchery? Or is it another character, investigating Drood's murder? Dickens did\"\n },\n {\n \"docid\": \"72751\",\n \"score\": 0.5329743027687073,\n \"snippet\": \"---\\ntitle: 5 Powerful Examples of Disguise As A Device In Fiction\\ndate: 2020-04-14\\n---\\nIn this blog, we show you five examples of disguise as a device in fiction. The examples from classic and modern literature explain how writers have used disguise in their stories to remarkable effect.\\n\\n5 Powerful Examples of Disguise As A Device In Fiction\\n\\nYou will discover:\\n\\n- How Shakespeare mastered disguise in his plays.\\n\\n- How Charlotte Bronte showed another side to her imposing hero Rochester.\\n\\n- How Virginia Woolf smashed gender stereotypes in her feminist masterpiece Orlando.\\n\\n- How Anne Fine helped a divorced father connect with his family in Madame Doubtfire.\\n\\n- How Anne Rice showed us human weakness and desire through immortal beings in Interview With The Vampire.\\n\\nWhy Use Disguise In Your Fiction?\\n\\nWe all know that disguise is a common storytelling device. It can be used to express a theme, illumine a character, or drive a plot. When a character in a short story, script, or novel disguises their identity, they could be seeking a truth or revealing a deceit.\\n\\nThe obfuscation always has a reason that fits in with the plot or theme of the story.\\n\\nAs a writer, you can use this sort of clever concealment or literary trickery to subvert stereotypes or play with readers' expectations. It is a powerful resource available to you as a writer.\\n\\nIt is also, I'm sure you will agree, a lot of fun to write.\\n\\nBrilliant Disguise\\n\\nWhile a superhero may wear a mask, he wears it as a concealment. A mask seems to 'seal' or close off an identity \\u2013 whereas a disguise is more about taking on or absorbing another identity, sometimes more than one identity, without abandoning a core character or personality. It's a form of character camouflage that is important to the story or a character's inner transformation.\\n\\n1. Mastering Disguise\\n\\nRecently, I was reading about Giuseppe Dosi. In the 1920s, he was possibly one of Italy's greatest detectives, so much so that he could have easily been a fictional creation.\\n\\nThis former actor was a master of disguises and he used disguise as a method to solve crime. In fact, his approach became known as detectival transformation. His personas included a femme fatal, priest, banker, and a war veteran with a 'bum' leg. All his identities had their own fake IDs and penmanship.\\n\\nShakespeare was, of course\"\n },\n {\n \"docid\": \"95934\",\n \"score\": 0.5266444683074951,\n \"snippet\": \"---\\ntitle: What's the Name of That Book??? discussion\\nauthor: Author add link photo\\ndate: 2017-09-06\\n---\\nWhat's the Name of That Book??? discussion\\n\\n\\u25ba Suggest books for me\\n\\n>\\n\\nWoman disguised as a man\\n\\nYou might try looking through the whole \\\"Suggest books for me\\\" folder. There are multiple threads with this theme.\\n\\nThe Masqueraders this one has it all--sister disguised as a man and brother disguised as a woman. What more can anyone ask, right? It is also by Georgette Heyer which means it's almost guaranteed to be good.\\n\\nUnmasking Miss Appleby - this Regency fantasy romance has the heroine using actual magic to turn herself into a man as opposed to dressing as one, which paradoxically makes the charade a lot more believable.\\n\\nThe protagonist of The Third Witch, a young girl, disguises herself as a serving boy to infiltrate the castle of her enemy, Macbeth (of the Shakespeare play).\\n\\nToward the end of Dating Hamlet: Ophelia's Story, Ophelia dresses as a boy to reenter Elsinore after faking her death.\\n\\nLove Disguised also features a girl dressing up as a man.\\n\\nToward the end of Dating Hamlet: Ophelia's Story, Ophelia dresses as a boy to reenter Elsinore after faking her death.\\n\\nLove Disguised also features a girl dressing up as a man.\\n\\nRidiculous by D.L. Carter. Regency era. Woman dresses as a man to save her family from financial ruin.\\n\\nThe Horse and His Boy by C. S. Lewis has someone doing this, and she's an important character, although not the protagonist. The Horse and His Boy\\n\\nSisters of Shiloh by Kathy Hepinstall features 2 sisters who disguise themselves as men and join the Confederate Army\\u2014one seeking vengeance on the battlefield, the other finding love.\\n\\nI\\n\\nI\\n\\nPaladin by Sally Slater.\\n\\nthe h is not your typical maria clara. she enjoys sword work and is good at it. in order to join a quest, she has to pretend to be a boy, and she is on par with other males in terms of one on one fights. the H is a team mate on this quest, one of the 1st to diacover her secret, he is scarred emotionally.\\n\\nthe h is not your typical maria clara. she enjoys sword work and\"\n },\n {\n \"docid\": \"65464\",\n \"score\": 0.5240164995193481,\n \"snippet\": \"---\\ntitle: Top 30: Historical Romances Where the Heroine Masquerades as a Man\\nauthor: Anne\\ndate: 2020-06-12\\n---\\nI love a good masquerade Regency. If you look on the Books by Plot Type, which categorizes books we have reviewed by general plot or trope, you will see its one of my largest trope categories\\u2026for a reason (it means I read them a lot because I love this trope a lot!). But I haven't distinguished the type of masquerade. Per reader request, I have compiled a list of some of my favorite (and reader favorite) Regencies where the heroine masquerades as a man.\\n\\nIn no particular order, the book covers are linked to Amazon for purchase (and your purchase helps sponsor the blog) and where the author and title are highlighted, you can click to read a Regency Reader review.\\n\\nSarah MacLean: Never Judge a Lady by Her Cover. By day she is Lady Georgiana, a Duke's sister, and by night a founder of London's most notorious gaming hell.\\n\\nLiana De la Rosa: To Tame a Scandalous Lady The best way to learn about horse breeding is to masquerade as the Earl's assistant horse trainer\\u2026even if that means giving up the luxuries of her former life.\\n\\nDarcy Burke: One Night of Scandal The Duke's sister has a side hustle as a male gossip columnist that is threatened by a sexy MP's discovery of her ruse.\\n\\nCat Sebastian: Unmasked by the Marquess. Readers call it Sebastian's take on Heyer's Frederica, with a non-binary MC who presents as both man and woman.\\n\\nEloisa James: Duchess By Night An older title, this features a heroine masquerading as a rake.\\n\\nGeorgette Heyer: The Corinthian An OG Regency featuring a heroine who cross-dresses to escape villainous family members and runs into a Corinthian.\\n\\nMary Jo Putney: Silk and Secrets A Victorian heroine seeks adventure as a man, but must reunite with her estranged husband to rescue her missing brother.\\n\\nSarah MacLean: Nine Rules to Break When Romancing a Rake. Heroine decides to break some rules and explore what rakes get to do.\\n\\nLynsay Sands: The Switch. Escaping a villainous Uncle, twins take turns masquerading as the other's brother.\\n\\nMary Jo Putney: The Rake Heroine masquerading as a steward\"\n },\n {\n \"docid\": \"29367\",\n \"score\": 0.5197827816009521,\n \"snippet\": \"---\\ntitle: Historical Fictionistas discussion\\nauthor: Author add link photo\\ndate: 2012-10-11\\n---\\nHistorical Fictionistas discussion\\n\\nRecommendations?\\n\\n>\\n\\nCrime in 19th Century London?\\n\\ndate\\n\\nnewest \\u00bb\\n\\nmessage 1:\\n\\nby\\n\\nAllison\\n\\n(new)\\n\\nOct 11, 2012 07:57AM\\n\\nHey you guys...I would love some recommendations on books set in 19th century London revolving around crime (other than Jack the Ripper). Fiction and non-fiction would both be great!\\n\\nreply\\n\\n|\\n\\nflag\\n\\nAnything by Anne Perry in the Inspector Monk and the Thomas Pitt series. They start with The Face of a Stranger and The Cater Street Hangman, respectively. Monk is 1860s and Pitt somewhat later.\\n\\nDickens, of course. But no one does 19th-century London crime quite like Perry.\\n\\nSome of Tasha Alexander's Lady Emily Ashton novels, starting with And Only to Deceive, are also set in London, but they are more oriented toward elite crime than the nitty-gritty grim reality of the Isle of Dogs.\\n\\nDickens, of course. But no one does 19th-century London crime quite like Perry.\\n\\nSome of Tasha Alexander's Lady Emily Ashton novels, starting with And Only to Deceive, are also set in London, but they are more oriented toward elite crime than the nitty-gritty grim reality of the Isle of Dogs.\\n\\nDavid Liss's Benjamin Weaver series is my favorite. It begins with A Conspiracy of Paper, a HF murder/mystery/thriller novel about a financial crisis in 19th century London. You'll love Benjamin and learn a lot about economics (without being bored to tears) because Liss is such a master at inserting historical details in creative ways. David Liss\\n\\nI love historical mysteries, so here's a few of my favorites set in London in the 19th century:\\n\\nThe Yard\\n\\nThe Solitary House\\n\\nA gritty series:\\n\\nWill Thomas, the first title isSome Danger Involved\\n\\nA couple of aristocratic series:\\n\\nC.S. Harris, Regency era with the first in the series being What Angels Fear\\n\\nCharles Finch,A Beautiful Blue Death is first in the series\\n\\nEarly forensics:\\n\\nDevoured by D.E. Meredith\\n\\nEnjoy!\\n\\nThe Yard\\n\\nThe Solitary House\\n\\nA gritty series:\\n\\nWill Thomas, the first title isSome Danger Involved\\n\\nA couple of arist\"\n }\n]", "invoking_actions": [{"name": "search", "arguments": {}, "id": "e597d7c7-bfa3-4158-8d45-bf01379bba4d", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.358665, "benchmark_cost": 0.0} +{"event": "action", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 8, "action": {"name": "search", "arguments": {"query": "Victorian novel sea storm rescue climax encrypted document spy villain send abroad"}, "id": "1e828f5c-c72f-4eb9-b664-ebd9dedab014", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.48636, "benchmark_cost": 0.0} +{"event": "observation", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 8, "observation": {"result": "[\n {\n \"docid\": \"77939\",\n \"score\": 0.5182424783706665,\n \"snippet\": \"---\\ntitle: 12 Historical Fiction Books That Bring the Victorian Era to Life\\ndate: 2024-07-09\\n---\\nFeatures summary\\n\\n12 Historical Fiction Books That Bring the Victorian Era to Life\\n\\nImmerse yourself in the Victorian era with these 12 captivating historical fiction novels. Each book brings to life the complexities, challenges, and beauty of the period, offering a detailed look at the social, cultural, and technological changes of the time.\\n\\n12 Historical Fiction Books That Bring the Victorian Era to Life (Picture Credit - Instagram)\\n\\nThe Victorian era, spanning from 1837 to 1901, was a period of significant social, cultural, and technological change. This era saw the rise of the British Empire, industrial advancements, and shifts in societal norms. Historical fiction set in this time offers a captivating glimpse into the past, blending rich storytelling with historical details. Here are 12 historical fiction books that beautifully capture the essence of the Victorian era.\\n\\n1. The Crimson Petal and the White by Michel Faber\\n\\nMichel Faber's 'The Crimson Petal and the White' provides a vivid and gritty portrayal of Victorian London. The novel follows Sugar, a determined and intelligent prostitute, as she navigates the city's dark underbelly and attempts to climb the social ladder. Faber's meticulous attention to historical detail and complex characters make this a compelling read that immerses readers in the era's harsh realities and opulent surroundings.\\n\\n2. Fingersmith by Sarah Waters\\n\\n'Fingersmith' by Sarah Waters is a riveting tale of deception, betrayal, and unexpected twists. Set in Victorian England, the story revolves around Sue Trinder, a young woman raised among thieves, and her involvement in a con to defraud a wealthy heiress. Waters' masterful storytelling and rich depiction of Victorian society's class divides and criminal underworld makes this novel a gripping exploration of love and trust.\\n\\n3. The Essex Serpent by Sarah Perry\\n\\nSarah Perry's 'The Essex Serpent' is a beautifully written novel that blends historical fiction with gothic elements. Set in 1893, the story follows Cora Seaborne, a widow with a passion for natural history, as she moves to Essex and investigates rumours of a mythical serpent. Perry's evocative prose and exploration of themes like faith, science, and friendship capture the intellectual and societal currents of the Victorian era.\\n\\n4. The Light Between Oceans by M.L. Sted\"\n },\n {\n \"docid\": \"67868\",\n \"score\": 0.5018195509910583,\n \"snippet\": \"---\\ntitle: 25 of the best spy thrillers\\ndate: 2024-01-09\\n---\\nBooks\\n\\n25 of the best spy thrillers\\n\\nWe can't resist a dip into the mysterious world of the spy thriller. With secrets, lies, conspiracies and undercover plots abound, these books span the national and the international, blending the personal and the political and showing how they are inextricably linked.\\n\\nFrom the classic to the contemporary, here are some of the best spy thrillers around.\\n\\nThe best spy thriller books\\n\\nCasino Royale by Ian Fleming\\n\\nCasino Royale by Ian Fleming\\n\\nIn Casino Royale, the first of Ian Fleming's 007 adventures, a game of cards is James Bond's only chance to bring down Le Chiffre, French communist and paymaster of the Soviet murder organization SMERSH. But Bond soon discovers that there is far more at stake than money.\\n\\nThis is a book that's packed will thrills and suspense. It's an incredibly satisfying read that showcases everything we know and love about the Bond stories \\u2013 chilling, sadistic villains, sensuous, fiery love affairs and the smooth, sophisticated secret agent himself.\\n\\nThe Spy Coast by Tess Gerritsen\\n\\nThe Spy Coast by Tess Gerritsen\\n\\nMaggie Bird lives a quiet life as a retiree in a seaside town. She never talks about her past life as a former spy. But when an unidentified body is left on Maggie's doorway, she knows it's a calling card from old times. Enter the 'Martini Club' \\u2013 Maggie's group of former spy friends. Together, they must solve the mystery and stay one step ahead of law enforcement.\\n\\nThis is the first instalment in a brand-new espionage series from Tess Gerritsen, author of the beloved Rizzoli and Isles detective series.\\n\\nThe Day of the Jackal by Frederick Forsyth\\n\\nThe Day of the Jackal by Frederick Forsyth\\n\\nThis book by former RAF pilot and investigative journalist Frederick Forsyth is one of the most celebrated thrillers ever written. It's intelligent, chilling and 100% unputdownable.\\n\\nIt is 1963 and an anonymous Englishman has been hired by the O.A.S. to murder General de Galle. A failed attempt means the target will be nearly impossible to get to. But this latest plot involves a lethal weapon: an assassin of legendary talent. Known only as The Jackal, this remorseless and deadly killer must be stopped \\u2013 but\"\n },\n {\n \"docid\": \"42402\",\n \"score\": 0.49579551815986633,\n \"snippet\": \"---\\ntitle: The Scarlet Pimpernel - Wikipedia\\nauthor: Authority control databases\\ndate: 2002-09-10\\n---\\nname: The Scarlet Pimpernel\\nauthor: Baroness Orczy\\ncountry: United Kingdom\\nlanguage: English\\ngenre: Historical fiction, adventure fiction\\nset_in: 1792, during the early stages of the French Revolution\\npublisher: Greening\\npub_date: 1905\\npages: 319\\npreceded_by: The First Sir Percy\\nfollowed_by: Sir Percy Leads the Band\\n\\nThe Scarlet Pimpernel is the first novel in a series of historical fiction by Baroness Orczy, published in 1905. It was written after her stage play of the same title (co-authored with her husband Montague Barstow) enjoyed a long run in London, having opened in Nottingham in 1903.\\n\\nThe novel is set during the Reign of Terror following the start of the French Revolution. The title is the nom de guerre of its hero and protagonist, a chivalrous Englishman who rescues aristocrats before they are sent to the guillotine. Sir Percy Blakeney leads a double life: apparently nothing more than a wealthy fop, but in reality, a formidable swordsman and a quick-thinking master of disguise and escape artist. The band of gentlemen who assist him are the only ones who know of his secret identity. He is known by his symbol, a simple flower, the scarlet pimpernel (Anagallis arvensis).\\n\\nOpening at the New Theatre in London's West End on 5 January 1905, the play became a favourite of British audiences, eventually playing more than 2,000 performances and becoming one of the most popular shows staged in London. Published after the success of the play, the novel was an immediate success, gaining Orczy a following of readers in Britain and the rest of the world. The stage play and subsequent novel, with their hero and villain, were so popular that they inspired a revival of classic villainy at the time.\\n\\nOrczy's premise of a daring hero who cultivates a secret identity disguised by a meek or ineffectual manner proved enduring. Zorro, Doctor Syn, the Shadow, the Spider, the Green Hornet, the Phantom, Superman and Batman followed within a few decades, and the trope remains a popular one in serial fiction today. Read by\"\n },\n {\n \"docid\": \"36824\",\n \"score\": 0.49507761001586914,\n \"snippet\": \"---\\ntitle: Kidnapped (novel) - Wikipedia\\nauthor: Authority control databases National Germany Spain\\ndate: 2005-01-30\\n---\\nname: Kidnapped\\nauthor: Robert Louis Stevenson\\ncountry: Scotland\\nlanguage: Victorian era Scottish English, Lowland Scots, Highland English\\ngenre: Adventure novelHistorical novel\\npublisher: Cassell and Company Ltd\\nrelease_date: 1886\\npages: 136\\ndewey: 823/.8 21\\ncongress: PR5484 .K5 2000\\noclc: 43167976\\nfollowed_by: Catriona (1893)\\n\\nKidnapped is a historical fiction adventure novel by Scottish author Robert Louis Stevenson, written as a boys' novel and first published in the magazine Young Folks from May to July 1886. The novel has attracted the praise and admiration of writers as diverse as Henry James, Jorge Luis Borges, and Hilary Mantel. A sequel, Catriona, was published in 1893.\\n\\nThe narrative is written in English with some dialogue in Lowland Scots, a Germanic language that evolved from an earlier incarnation of English.\\n\\nKidnapped is set around real 18th-century Scottish events, notably the \\\"Appin Murder\\\" and the Highland Clearances, which occurred in the aftermath of the Jacobite rising of 1745. Many of the characters are real people, including one of the principals, Alan Breck Stewart. The political situation of the time is portrayed from multiple viewpoints, and the Scottish Highlanders are treated sympathetically.\\n\\nThe full title of the book is Kidnapped: Being Memoirs of the Adventures of David Balfour in the Year 1751: How he was Kidnapped and Cast away; his Sufferings in a Desert Isle; His Journey in the Wild Highlands; his acquaintance with Alan Breck Stewart and other notorious Highland Jacobites; with all that he suffered at the hands of his Uncle, Ebenezer Balfour of Shaws, falsely so-called: Written by Himself and now set forth by Robert Louis Stevenson.\\n\\nPlot\\n\\nThe novel opens in the (fictional) village of Essendean in the Ettrick Forest area of the Scottish Lowlands in 1751. The main character and narrator is 17-year-old David Balfour\"\n },\n {\n \"docid\": \"48916\",\n \"score\": 0.4882397949695587,\n \"snippet\": \"---\\ntitle: Plotting the Mystery Novel\\n---\\n|\\n\\n|\\n\\nPlotting the Mystery NovelThe classic mystery is popular fiction which follows a specific formula. Clever writers may try to change the formula, but the most clever will cling to it for a very good reason. They work within the bounds of the formula because it works! The following outline serves the modern mystery novel, as defined by editors and publishers. A typical story will contain 60,000 to 65,000 words (205 manuscript pages) and will be divided into 12 chapters, each approximately 17 pages in length.\\n\\nThe Classic 12-Chapter Mystery Formula\\n\\nAct IIntroduction of the crime (mystery) and the sleuth Chapter 1 A. Disclose the crime and mystery to be solved. The crime must capture the imagination. It should have been committed in an extraordinary way and either the victim the perpetuator, or both, should be unusual. Give the reader enough information about the victim to make them truly care that the perpetrator is found out and that justice is served. B. Early in the story, clues should be revealed which suggest both physical and psychological aspects of the initial crime. Those clues should point to suspects and motive which will cary the sleuth to the end of Act I. Some clues should point the sleuth in the right direction, others may not be obvious or be recognized as actual clues unto later in the story. C. Introduce the sleuth who will solve the crime early, and have him or her do or say something very clever or unexpected which will establish that person as unique. Create this character with care. His or her personality should be interesting enough to sustain the interest of the reader to the very last page. (or through an entire series of books). It is not necessary to disclose all aspects of the sleuth's personality at the onset. Let the description unfold gradually to sustain interest. Do reveal enough background to let the reader understand the world in which the protagonist functions. (Small town sheriff, Scotland Yard detective, Pinkerton agent in the old West, country squire, investigative reporter in New York City, etc.) D. Ground the reader in the time and place where the crime occurs. It is often useful to include some sort of symbol, an object or a person, in the opening scene which serves as a metaphor for what occurs in the story. The reappearance of this symbol at the conclusion of the story will create a certain organic unity. E. Begin\"\n }\n]", "invoking_actions": [{"name": "search", "arguments": {}, "id": "1e828f5c-c72f-4eb9-b664-ebd9dedab014", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.48636, "benchmark_cost": 0.0} +{"event": "action", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 9, "action": {"name": "search", "arguments": {"query": "\"woman disguised as man\" Victorian London accused theft businessman rescue storm ship"}, "id": "3061017b-47d1-47e6-a98d-70e4eff41105", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.6306100000000001, "benchmark_cost": 0.0} +{"event": "observation", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 9, "observation": {"result": "[\n {\n \"docid\": \"19305\",\n \"score\": 0.6136258840560913,\n \"snippet\": \"---\\ntitle: Ten of the best disguises in literature\\nauthor: John Mullan\\ndate: 2010-09-25\\n---\\nThe Odyssey, by Homer\\n\\nOdysseus arrives back at his island of Ithaca disguised as a beggar. He is recognised only by his old dog Argus (animals always see through disguises), which dies of joy on the spot. In his disguise, our hero is able to see who has been loyal to him and who has not.\\n\\nMeasure for Measure, by William Shakespeare\\n\\nThe Duke who governs Vienna wants to see what his underlings will get up to in his absence. So he asks his friend Friar Thomas for some monkish garb: \\\"Supply me with the habit and instruct me / How I may formally in person bear me / Like a true friar\\\". It works, and not even his most devoted courtiers recognise him until he finally unveils himself.\\n\\nThe Monk, by Matthew Lewis\\n\\nAnother monkish disguise. Sexy young Matilda lusts after Father Ambrosio, the most pious monk in Madrid. So she dresses up as a young novice monk and finds her way into the monastery. In her cell she reveals herself to Ambrosio, who cannot resist her charms. It turns out that she is in fact a demon.\\n\\nJane Eyre, by Charlotte Bront\\u00eb\\n\\nOne of the great episodes of transvestism in literature comes when Rochester togs himself up as a Gypsy woman to read the palms of the guests he has invited to Thornfield. Blanche Ingram, Jane's rival for his affections, gets uncomforting news, but Jane is told \\\"the cup of bliss\\\" is going to be offered to her.\\n\\nEast Lynne, by Mrs Henry Wood\\n\\nLady Isabel Vane loses her happy home and family when she conducts an adulterous affair with the utterly caddish Francis Levinson. Having learned the error of her ways, she returns to be governess to her own children, disguised by blue-lensed glasses, hair turned white from shock after a train crash and a scarred mouth.\\n\\nThe Mystery of Edwin Drood, by Charles Dickens\\n\\nDick Datchery arrives in the town of Cloisterham, apparently a detective in disguise (he wears a wig). He (or she?) keeps watch over John Jasper, choirmaster and secret drug addict. Drood has disappeared: is he disguised as Datchery? Or is it another character, investigating Drood's murder? Dickens did\"\n },\n {\n \"docid\": \"95934\",\n \"score\": 0.5420852899551392,\n \"snippet\": \"---\\ntitle: What's the Name of That Book??? discussion\\nauthor: Author add link photo\\ndate: 2017-09-06\\n---\\nWhat's the Name of That Book??? discussion\\n\\n\\u25ba Suggest books for me\\n\\n>\\n\\nWoman disguised as a man\\n\\nYou might try looking through the whole \\\"Suggest books for me\\\" folder. There are multiple threads with this theme.\\n\\nThe Masqueraders this one has it all--sister disguised as a man and brother disguised as a woman. What more can anyone ask, right? It is also by Georgette Heyer which means it's almost guaranteed to be good.\\n\\nUnmasking Miss Appleby - this Regency fantasy romance has the heroine using actual magic to turn herself into a man as opposed to dressing as one, which paradoxically makes the charade a lot more believable.\\n\\nThe protagonist of The Third Witch, a young girl, disguises herself as a serving boy to infiltrate the castle of her enemy, Macbeth (of the Shakespeare play).\\n\\nToward the end of Dating Hamlet: Ophelia's Story, Ophelia dresses as a boy to reenter Elsinore after faking her death.\\n\\nLove Disguised also features a girl dressing up as a man.\\n\\nToward the end of Dating Hamlet: Ophelia's Story, Ophelia dresses as a boy to reenter Elsinore after faking her death.\\n\\nLove Disguised also features a girl dressing up as a man.\\n\\nRidiculous by D.L. Carter. Regency era. Woman dresses as a man to save her family from financial ruin.\\n\\nThe Horse and His Boy by C. S. Lewis has someone doing this, and she's an important character, although not the protagonist. The Horse and His Boy\\n\\nSisters of Shiloh by Kathy Hepinstall features 2 sisters who disguise themselves as men and join the Confederate Army\\u2014one seeking vengeance on the battlefield, the other finding love.\\n\\nI\\n\\nI\\n\\nPaladin by Sally Slater.\\n\\nthe h is not your typical maria clara. she enjoys sword work and is good at it. in order to join a quest, she has to pretend to be a boy, and she is on par with other males in terms of one on one fights. the H is a team mate on this quest, one of the 1st to diacover her secret, he is scarred emotionally.\\n\\nthe h is not your typical maria clara. she enjoys sword work and\"\n },\n {\n \"docid\": \"72751\",\n \"score\": 0.5347636342048645,\n \"snippet\": \"---\\ntitle: 5 Powerful Examples of Disguise As A Device In Fiction\\ndate: 2020-04-14\\n---\\nIn this blog, we show you five examples of disguise as a device in fiction. The examples from classic and modern literature explain how writers have used disguise in their stories to remarkable effect.\\n\\n5 Powerful Examples of Disguise As A Device In Fiction\\n\\nYou will discover:\\n\\n- How Shakespeare mastered disguise in his plays.\\n\\n- How Charlotte Bronte showed another side to her imposing hero Rochester.\\n\\n- How Virginia Woolf smashed gender stereotypes in her feminist masterpiece Orlando.\\n\\n- How Anne Fine helped a divorced father connect with his family in Madame Doubtfire.\\n\\n- How Anne Rice showed us human weakness and desire through immortal beings in Interview With The Vampire.\\n\\nWhy Use Disguise In Your Fiction?\\n\\nWe all know that disguise is a common storytelling device. It can be used to express a theme, illumine a character, or drive a plot. When a character in a short story, script, or novel disguises their identity, they could be seeking a truth or revealing a deceit.\\n\\nThe obfuscation always has a reason that fits in with the plot or theme of the story.\\n\\nAs a writer, you can use this sort of clever concealment or literary trickery to subvert stereotypes or play with readers' expectations. It is a powerful resource available to you as a writer.\\n\\nIt is also, I'm sure you will agree, a lot of fun to write.\\n\\nBrilliant Disguise\\n\\nWhile a superhero may wear a mask, he wears it as a concealment. A mask seems to 'seal' or close off an identity \\u2013 whereas a disguise is more about taking on or absorbing another identity, sometimes more than one identity, without abandoning a core character or personality. It's a form of character camouflage that is important to the story or a character's inner transformation.\\n\\n1. Mastering Disguise\\n\\nRecently, I was reading about Giuseppe Dosi. In the 1920s, he was possibly one of Italy's greatest detectives, so much so that he could have easily been a fictional creation.\\n\\nThis former actor was a master of disguises and he used disguise as a method to solve crime. In fact, his approach became known as detectival transformation. His personas included a femme fatal, priest, banker, and a war veteran with a 'bum' leg. All his identities had their own fake IDs and penmanship.\\n\\nShakespeare was, of course\"\n },\n {\n \"docid\": \"78562\",\n \"score\": 0.5340186357498169,\n \"snippet\": \"---\\ntitle: Disguised or living as a man (Sapphic Reading Challenge #33)\\ndate: 2021-08-26\\n---\\nThis week's category of the Sapphic Reading Challenge features books about main characters who are either women who disguise themselves as men out of necessity or genderqueer people who were assigned female at birth but might identify as nonbinary or as trans men (even though the book might not use those terms since most books on this list are historical romances).\\n\\nRead up on the rules of the Sapphic Reading Challenge\\n\\nIf you are only discovering the Sapphic Reading Challenge now, the good news is you can still join! It's a year-long challenge, so it runs until December 31, 2021. Read up on the rules of the Sapphic Reading Challenge and download your PDF so you can keep track of the books you read this year.\\n\\n15 sapphic books featuring main characters who are disguised or living as men\\n\\nI put together a list of 15 sapphic books featuring a character who lives their life as a man at least for major parts of the book. I hope you'll find at least one you'll enjoy.\\n\\nBackwards to Oregon by Jae\\n\\nLuke Hamilton has always been sure that she'd never marry. She accepted that she would spend her life alone when she chose to live her life disguised as a man.\\n\\nAfter working in a brothel for three years, Nora Macauley has lost all illusions about love. She no longer hopes for a man who will sweep her off her feet and take her away to begin a new, respectable life.\\n\\nBut now they find themselves married and on the way to Oregon in a covered wagon, with two thousand miles ahead of them.\\n\\nAvailable at:\\n\\nCrossing the Wide Forever by Missouri Vaun\\n\\nCody Walsh leaves Arkansas for California. Lured by stories of opportunity, even for women, Cody disguises herself as a man and prepares for the arduous journey west.\\n\\nLillie Ellis leaves New York to accept a post as a schoolteacher on the frontier near a small homestead she just inherited from her uncle. Lillie's ultimate desire is to become a painter, and she hopes the Kansas frontier will offer her the freedom to follow that dream. In the nineteenth century, a young woman has few options in the East that don't revolve around marriage and motherhood. Lillie is interested in neither.\\n\\nCody rescues Lill\"\n },\n {\n \"docid\": \"65464\",\n \"score\": 0.5245835781097412,\n \"snippet\": \"---\\ntitle: Top 30: Historical Romances Where the Heroine Masquerades as a Man\\nauthor: Anne\\ndate: 2020-06-12\\n---\\nI love a good masquerade Regency. If you look on the Books by Plot Type, which categorizes books we have reviewed by general plot or trope, you will see its one of my largest trope categories\\u2026for a reason (it means I read them a lot because I love this trope a lot!). But I haven't distinguished the type of masquerade. Per reader request, I have compiled a list of some of my favorite (and reader favorite) Regencies where the heroine masquerades as a man.\\n\\nIn no particular order, the book covers are linked to Amazon for purchase (and your purchase helps sponsor the blog) and where the author and title are highlighted, you can click to read a Regency Reader review.\\n\\nSarah MacLean: Never Judge a Lady by Her Cover. By day she is Lady Georgiana, a Duke's sister, and by night a founder of London's most notorious gaming hell.\\n\\nLiana De la Rosa: To Tame a Scandalous Lady The best way to learn about horse breeding is to masquerade as the Earl's assistant horse trainer\\u2026even if that means giving up the luxuries of her former life.\\n\\nDarcy Burke: One Night of Scandal The Duke's sister has a side hustle as a male gossip columnist that is threatened by a sexy MP's discovery of her ruse.\\n\\nCat Sebastian: Unmasked by the Marquess. Readers call it Sebastian's take on Heyer's Frederica, with a non-binary MC who presents as both man and woman.\\n\\nEloisa James: Duchess By Night An older title, this features a heroine masquerading as a rake.\\n\\nGeorgette Heyer: The Corinthian An OG Regency featuring a heroine who cross-dresses to escape villainous family members and runs into a Corinthian.\\n\\nMary Jo Putney: Silk and Secrets A Victorian heroine seeks adventure as a man, but must reunite with her estranged husband to rescue her missing brother.\\n\\nSarah MacLean: Nine Rules to Break When Romancing a Rake. Heroine decides to break some rules and explore what rakes get to do.\\n\\nLynsay Sands: The Switch. Escaping a villainous Uncle, twins take turns masquerading as the other's brother.\\n\\nMary Jo Putney: The Rake Heroine masquerading as a steward\"\n }\n]", "invoking_actions": [{"name": "search", "arguments": {}, "id": "3061017b-47d1-47e6-a98d-70e4eff41105", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.6306100000000001, "benchmark_cost": 0.0} +{"event": "action", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 10, "action": {"name": "search", "arguments": {"query": "historical fiction secretary clerk businessman theft accused cipher code smuggle villain"}, "id": "2b09b377-b2e3-4e3b-9754-617d59461ed4", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.7923000000000001, "benchmark_cost": 0.0} +{"event": "observation", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 10, "observation": {"result": "[\n {\n \"docid\": \"42402\",\n \"score\": 0.4924449026584625,\n \"snippet\": \"---\\ntitle: The Scarlet Pimpernel - Wikipedia\\nauthor: Authority control databases\\ndate: 2002-09-10\\n---\\nname: The Scarlet Pimpernel\\nauthor: Baroness Orczy\\ncountry: United Kingdom\\nlanguage: English\\ngenre: Historical fiction, adventure fiction\\nset_in: 1792, during the early stages of the French Revolution\\npublisher: Greening\\npub_date: 1905\\npages: 319\\npreceded_by: The First Sir Percy\\nfollowed_by: Sir Percy Leads the Band\\n\\nThe Scarlet Pimpernel is the first novel in a series of historical fiction by Baroness Orczy, published in 1905. It was written after her stage play of the same title (co-authored with her husband Montague Barstow) enjoyed a long run in London, having opened in Nottingham in 1903.\\n\\nThe novel is set during the Reign of Terror following the start of the French Revolution. The title is the nom de guerre of its hero and protagonist, a chivalrous Englishman who rescues aristocrats before they are sent to the guillotine. Sir Percy Blakeney leads a double life: apparently nothing more than a wealthy fop, but in reality, a formidable swordsman and a quick-thinking master of disguise and escape artist. The band of gentlemen who assist him are the only ones who know of his secret identity. He is known by his symbol, a simple flower, the scarlet pimpernel (Anagallis arvensis).\\n\\nOpening at the New Theatre in London's West End on 5 January 1905, the play became a favourite of British audiences, eventually playing more than 2,000 performances and becoming one of the most popular shows staged in London. Published after the success of the play, the novel was an immediate success, gaining Orczy a following of readers in Britain and the rest of the world. The stage play and subsequent novel, with their hero and villain, were so popular that they inspired a revival of classic villainy at the time.\\n\\nOrczy's premise of a daring hero who cultivates a secret identity disguised by a meek or ineffectual manner proved enduring. Zorro, Doctor Syn, the Shadow, the Spider, the Green Hornet, the Phantom, Superman and Batman followed within a few decades, and the trope remains a popular one in serial fiction today. Read by\"\n },\n {\n \"docid\": \"29367\",\n \"score\": 0.4854736626148224,\n \"snippet\": \"---\\ntitle: Historical Fictionistas discussion\\nauthor: Author add link photo\\ndate: 2012-10-11\\n---\\nHistorical Fictionistas discussion\\n\\nRecommendations?\\n\\n>\\n\\nCrime in 19th Century London?\\n\\ndate\\n\\nnewest \\u00bb\\n\\nmessage 1:\\n\\nby\\n\\nAllison\\n\\n(new)\\n\\nOct 11, 2012 07:57AM\\n\\nHey you guys...I would love some recommendations on books set in 19th century London revolving around crime (other than Jack the Ripper). Fiction and non-fiction would both be great!\\n\\nreply\\n\\n|\\n\\nflag\\n\\nAnything by Anne Perry in the Inspector Monk and the Thomas Pitt series. They start with The Face of a Stranger and The Cater Street Hangman, respectively. Monk is 1860s and Pitt somewhat later.\\n\\nDickens, of course. But no one does 19th-century London crime quite like Perry.\\n\\nSome of Tasha Alexander's Lady Emily Ashton novels, starting with And Only to Deceive, are also set in London, but they are more oriented toward elite crime than the nitty-gritty grim reality of the Isle of Dogs.\\n\\nDickens, of course. But no one does 19th-century London crime quite like Perry.\\n\\nSome of Tasha Alexander's Lady Emily Ashton novels, starting with And Only to Deceive, are also set in London, but they are more oriented toward elite crime than the nitty-gritty grim reality of the Isle of Dogs.\\n\\nDavid Liss's Benjamin Weaver series is my favorite. It begins with A Conspiracy of Paper, a HF murder/mystery/thriller novel about a financial crisis in 19th century London. You'll love Benjamin and learn a lot about economics (without being bored to tears) because Liss is such a master at inserting historical details in creative ways. David Liss\\n\\nI love historical mysteries, so here's a few of my favorites set in London in the 19th century:\\n\\nThe Yard\\n\\nThe Solitary House\\n\\nA gritty series:\\n\\nWill Thomas, the first title isSome Danger Involved\\n\\nA couple of aristocratic series:\\n\\nC.S. Harris, Regency era with the first in the series being What Angels Fear\\n\\nCharles Finch,A Beautiful Blue Death is first in the series\\n\\nEarly forensics:\\n\\nDevoured by D.E. Meredith\\n\\nEnjoy!\\n\\nThe Yard\\n\\nThe Solitary House\\n\\nA gritty series:\\n\\nWill Thomas, the first title isSome Danger Involved\\n\\nA couple of arist\"\n },\n {\n \"docid\": \"41618\",\n \"score\": 0.47932177782058716,\n \"snippet\": \"---\\ntitle: Women Looking Away in Period Costume\\ndate: 2021-08-10\\n---\\nWomen Looking Away in Period Costume\\n\\nHistorical fiction with one little thing in common.\\n\\nAugust 10, 2021\\n\\nWe're going to let you in on a librarian secret: sometimes we judge a book by its cover. The fastest way to find historical fiction is to look for a woman in period costume looking (or walking) away!\\n\\nThe Address\\n\\nFiona Davis\\n\\nInterior designer Bailey Camdenis leaps at a chance to renovate her heiress cousin's lavish apartment at The Dakota, and learns the scandalous history of a distant ancestor's connection to the murder of the building's architect a century earlier.\\n\\nThe Alice Network\\n\\nKate Quinn\\n\\nIn 1947, pregnant Charlie St. Clair, an American college girl banished from her family, arrives in London to find out what happened to her beloved cousin Rose, who disappeared in Nazi-occupied France during the war, and meets a former spy who, torn apart by betrayal, agrees to help her on her mission.\\n\\nAll the Flowers in Paris\\n\\nSarah Jio\\n\\nA tale told from alternating viewpoints follows the experiences of a Parisian woman who awakens with no memory of her past before discovering a mysterious cache of letters written by a young woman of Jewish ancestry during the Nazi occupation.\\n\\nThe Atomic City Girls\\n\\nJanet Beard\\n\\nWorking in support of the war effort, June Walker begins an affair with a young Jewish physicist in hopes of uncovering what the government's end goal is, until the bombing of Hiroshima reveals the truth about what they are doing.\\n\\nAtomic Love\\n\\nJennie Fields\\n\\nRecruited by the FBI to spy on her former lover, a guilt-riddled Manhattan Project physicist becomes torn between lingering feelings for her ex and her growing attraction to a special agent, a former prisoner of war.\\n\\nBand of Sisters\\n\\nLauren Willig\\n\\nEschewed by her wealthy graduated classmates, a former scholarship student reluctantly volunteers to help World War I French civilians before finding herself surrounded by desperate families in villages decimated by German bombs\\n\\nThe Book of Lost Names\\n\\nKristin Harmel\\n\\nEscaping from Paris in 1942 after the arrest of her father, a Polish Jew, a graduate student finds refuge in a small mountain town, where she forges identity documents to help hundreds of Jewish children flee the Nazis.\\n\\nChurchill's Secret Messenger\\n\\nAlan Hlad\\n\\nRecruited from Churchill's typing pool to become an undercover spy\"\n },\n {\n \"docid\": \"51328\",\n \"score\": 0.4763755202293396,\n \"snippet\": \"---\\ntitle: Celebrating History's Bad-Ass Women: PW Talks with Greer Macallister\\nauthor: Claire Kirch\\ndate: 2017-02-28\\n---\\nGreer Macallister's historical novel Girl in Disguise (Sourcebooks) is a rollicking mashup of the real and imagined exploits of Kate Warne, the first female operative employed by the famed Pinkerton Detective Agency.\\n\\nHow did you come to write Girl in Disguise?\\n\\nWhen I first heard Kate's story a few years ago, I was floored. This woman was such a pioneer. She was a female detective in the 1850s, when it was completely unheard-of, and she was so good at it that she was assigned by Allan Pinkerton to run her own division. She helped save Abraham Lincoln's life as he made his way to his inauguration. She was an undercover spy for the Union during the Civil War. Somebody needed to get her story out there, and I figured it might as well be me!\\n\\nYou mention in your author's note that very little is known about Kate Warne; there aren't even verified photos of her--partially because she was a spy and also because Pinkerton Agency records were lost during the Chicago Fire of 1871. How much of her story is based on historical sources and how much did you have to imagine?\\n\\nThose gaps in the historical record made Kate the perfect subject for historical fiction--a little history and a lot of fiction. We know the facts on a handful of cases she worked, like the Adams Express case and one where she posed as a fortune-teller to catch a poisoner. Her role in thwarting the Lincoln assassination attempt in Baltimore is documented. But a lot of the rest is just open space, so I got to choose how to fill it in.\\n\\nWere the lives of Warne's male colleagues--including Allan Pinkerton--similarly shrouded in mystery?\\n\\nNot to the same degree, although some operatives' lives were better-documented than others. Pinkerton himself wrote and published a lot about his own prowess, so with him it's the opposite--tons of information, but not all reliable. For the cast of characters around Kate, I drew on some information about her real colleagues at the time, but mostly I combined and synthesized and created. It's all about balancing story and history to make the best possible experience for the reader.\\n\\nKate Warne is a mature woman, a widow, when she\"\n },\n {\n \"docid\": \"67868\",\n \"score\": 0.47207605838775635,\n \"snippet\": \"---\\ntitle: 25 of the best spy thrillers\\ndate: 2024-01-09\\n---\\nBooks\\n\\n25 of the best spy thrillers\\n\\nWe can't resist a dip into the mysterious world of the spy thriller. With secrets, lies, conspiracies and undercover plots abound, these books span the national and the international, blending the personal and the political and showing how they are inextricably linked.\\n\\nFrom the classic to the contemporary, here are some of the best spy thrillers around.\\n\\nThe best spy thriller books\\n\\nCasino Royale by Ian Fleming\\n\\nCasino Royale by Ian Fleming\\n\\nIn Casino Royale, the first of Ian Fleming's 007 adventures, a game of cards is James Bond's only chance to bring down Le Chiffre, French communist and paymaster of the Soviet murder organization SMERSH. But Bond soon discovers that there is far more at stake than money.\\n\\nThis is a book that's packed will thrills and suspense. It's an incredibly satisfying read that showcases everything we know and love about the Bond stories \\u2013 chilling, sadistic villains, sensuous, fiery love affairs and the smooth, sophisticated secret agent himself.\\n\\nThe Spy Coast by Tess Gerritsen\\n\\nThe Spy Coast by Tess Gerritsen\\n\\nMaggie Bird lives a quiet life as a retiree in a seaside town. She never talks about her past life as a former spy. But when an unidentified body is left on Maggie's doorway, she knows it's a calling card from old times. Enter the 'Martini Club' \\u2013 Maggie's group of former spy friends. Together, they must solve the mystery and stay one step ahead of law enforcement.\\n\\nThis is the first instalment in a brand-new espionage series from Tess Gerritsen, author of the beloved Rizzoli and Isles detective series.\\n\\nThe Day of the Jackal by Frederick Forsyth\\n\\nThe Day of the Jackal by Frederick Forsyth\\n\\nThis book by former RAF pilot and investigative journalist Frederick Forsyth is one of the most celebrated thrillers ever written. It's intelligent, chilling and 100% unputdownable.\\n\\nIt is 1963 and an anonymous Englishman has been hired by the O.A.S. to murder General de Galle. A failed attempt means the target will be nearly impossible to get to. But this latest plot involves a lethal weapon: an assassin of legendary talent. Known only as The Jackal, this remorseless and deadly killer must be stopped \\u2013 but\"\n }\n]", "invoking_actions": [{"name": "search", "arguments": {}, "id": "2b09b377-b2e3-4e3b-9754-617d59461ed4", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.7923000000000001, "benchmark_cost": 0.0} +{"event": "action", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 11, "action": {"name": "search", "arguments": {"query": "novel Victorian business deal heroine secretary shipwreck tempest rescue accused steal papers"}, "id": "d6174f08-75c7-4361-8b65-2d9cb4385b65", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.97135, "benchmark_cost": 0.0} +{"event": "observation", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 11, "observation": {"result": "[\n {\n \"docid\": \"77939\",\n \"score\": 0.5016008615493774,\n \"snippet\": \"---\\ntitle: 12 Historical Fiction Books That Bring the Victorian Era to Life\\ndate: 2024-07-09\\n---\\nFeatures summary\\n\\n12 Historical Fiction Books That Bring the Victorian Era to Life\\n\\nImmerse yourself in the Victorian era with these 12 captivating historical fiction novels. Each book brings to life the complexities, challenges, and beauty of the period, offering a detailed look at the social, cultural, and technological changes of the time.\\n\\n12 Historical Fiction Books That Bring the Victorian Era to Life (Picture Credit - Instagram)\\n\\nThe Victorian era, spanning from 1837 to 1901, was a period of significant social, cultural, and technological change. This era saw the rise of the British Empire, industrial advancements, and shifts in societal norms. Historical fiction set in this time offers a captivating glimpse into the past, blending rich storytelling with historical details. Here are 12 historical fiction books that beautifully capture the essence of the Victorian era.\\n\\n1. The Crimson Petal and the White by Michel Faber\\n\\nMichel Faber's 'The Crimson Petal and the White' provides a vivid and gritty portrayal of Victorian London. The novel follows Sugar, a determined and intelligent prostitute, as she navigates the city's dark underbelly and attempts to climb the social ladder. Faber's meticulous attention to historical detail and complex characters make this a compelling read that immerses readers in the era's harsh realities and opulent surroundings.\\n\\n2. Fingersmith by Sarah Waters\\n\\n'Fingersmith' by Sarah Waters is a riveting tale of deception, betrayal, and unexpected twists. Set in Victorian England, the story revolves around Sue Trinder, a young woman raised among thieves, and her involvement in a con to defraud a wealthy heiress. Waters' masterful storytelling and rich depiction of Victorian society's class divides and criminal underworld makes this novel a gripping exploration of love and trust.\\n\\n3. The Essex Serpent by Sarah Perry\\n\\nSarah Perry's 'The Essex Serpent' is a beautifully written novel that blends historical fiction with gothic elements. Set in 1893, the story follows Cora Seaborne, a widow with a passion for natural history, as she moves to Essex and investigates rumours of a mythical serpent. Perry's evocative prose and exploration of themes like faith, science, and friendship capture the intellectual and societal currents of the Victorian era.\\n\\n4. The Light Between Oceans by M.L. Sted\"\n },\n {\n \"docid\": \"90027\",\n \"score\": 0.47871720790863037,\n \"snippet\": \"---\\ntitle: Storm and Silence\\ndate: 2014-04-02\\n---\\n| 'Blast, blast, blast!'\\n\\nThis article is a stub and thus, inadequate. Help Storm and Silence Wiki by expanding it. |\\n\\nStorm and Silence is the first novel in the eponymous series written by Robert Thier. It is free to read on the online community Wattpad and the Radish Fiction Mobile App. The first chapter was uploaded onto Wattpad on April 2014 and the novel was published electronically and in paperback on March 2016. It won the 2015 Award for Story of the Year and currently has a massive fanbase, with over 100 million reads on Wattpad.\\n\\nSynopsis[]\\n\\nWattpad Version[]\\n\\n\\\"It is your choice,\\\" he said, stepping so close to me that our lips were almost touching.\\n\\n\\\"Either do what I say - or get another job.\\\"\\n\\nMy heart stood still as I gazed up into his deep, dark, dangerous eyes...\\n\\nIn a world where women's only role in life is to sit at home and look pretty, Lilly is determined to fight for her freedom. There's only one problem: a powerful man blocking her way. [1]\\n\\nRadish/Published Version[]\\n\\nFreedom \\u2013 that is what Lilly Linton wants most in life. Not marriage, not a brood of squalling brats, and certainly not love, thank you very much!\\n\\nBut freedom is a rare commodity in 19th-century London, where girls are expected to spend their lives sitting at home, fully occupied with looking pretty. Lilly is at her wits' end \\u2013 until a chance encounter with a dark, dangerous and powerful stranger changes her life forever...[2]\\n\\nPlot[]\\n\\nTBA\\n\\nChapters[]\\n\\nWattpad Version[]\\n\\n- Arrested for Good Manners\\n\\n- Ape Bobby\\n\\n- Who He Really Is\\n\\n- Sweet and Solid\\n\\n- Driving Me Wild(ly)\\n\\n- Empire House\\n\\n- His Indecent Demands\\n\\n- Inventing a Sibling and Getting Poked in the Eye\\n\\n- File Fight\\n\\n- The Worst Fate Imaginable\\n\\n- The Dragon's Den\\n\\n- Practicing Impertinence\\n\\n- Ballroom Battle\\n\\n- The Sins of Mr Rikkard Ambrose\\n\\n- It gets mushy-gushy\\n\\n- Unsuitable Suitors\\n\\n- Return to the Game\\n\\n- The Peril of Flowers\\n\\n- The\"\n },\n {\n \"docid\": \"18321\",\n \"score\": 0.4750952124595642,\n \"snippet\": \"---\\ntitle: Bleak House - Wikipedia\\nauthor: Authority control databases\\ndate: 2004-01-25\\n---\\nname: Bleak House\\nimage_size: 200px\\nauthor: Charles Dickens\\nillustrator: Hablot Knight Browne (Phiz)\\ncover_artist: Hablot Knight Browne (Phiz)\\ncountry: England\\nlanguage: English\\ngenre: Novel\\npublisher: Bradbury & Evans\\npublished: Serialised 12 March 1852 \\u2013 12 September 1853, book form 12 September 1853\\npreceded_by: David Copperfield\\nfollowed_by: A Child's History of England\\n\\nBleak House is a novel by English author Charles Dickens, first published as a 20-episode serial between 12 March 1852 and 12 September 1853. The novel has many characters and several subplots, and is told partly by the novel's heroine, Esther Summerson, and partly by an omniscient narrator. At the centre of Bleak House is a long-running legal case in the Court of Chancery, Jarndyce and Jarndyce, which comes about because a testator has written several conflicting wills. In a preface to the 1853 first edition, Dickens said there were many actual precedents for his fictional case. One such was probably Thellusson v Woodford, in which a will read in 1797Constantine, Alison. The Restoration of Brodsworth Hall & Gardens, February 2007 historical address, at Tickhill & District Local History Society was contested and not determined until 1859. Though many in the legal profession criticised Dickens's satire as exaggerated, Bleak House helped support a judicial reform movement that culminated in the enactment of legal reform in the 1870s.\\n\\nSome scholars debate when Bleak House is set. The English legal historian Sir William Holdsworth sets the action in 1827; however, reference to preparation for the building of a railway in Chapter LV suggests the 1830s. A work of Gothic fiction depicting London as a murky city swathed in fog, Bleak House is credited with introducing urban fog to the novel, which would become a frequent characteristic of urban Gothic literature and film.Mighall, Robert (2007). \\\"Gothic\"\n },\n {\n \"docid\": \"5574\",\n \"score\": 0.4695277810096741,\n \"snippet\": \"---\\ntitle: My Ultimate List of 5-Star Historical Romance Novel Recommendations\\nauthor: Katherine Grant\\ndate: 2020-10-23\\n---\\n(This post is updated every time I read a historical romance that I consider 5-stars! Jump straight to the recommendations by clicking here)\\n\\nDear fellow Historical Romance fan,\\n\\nEvery now and then, I read a historical romance that leaves me with a massive book hangover. You know the feeling: I just want to be with those characters for the rest of my life, and I absolutely don't want to reemerge into the real world.\\n\\nThe only cure I know of is to find another fantastic historical romance. In case you suffer the same fate, I thought I'd share an ultimate, growing list of my 5-star reads (aka BEST ROMANCES EVER). Most of these will probably be Regency romances, but the list also includes historical romance subgenres like Victorian, Scottish, American, and more.\\n\\nBy the way, these are MY all-time favorite 5-star reads. I'll also tell you the average Goodreads rating, but I warn you up front that my opinion often differs from the herd. Also, I'm linking to where you can buy the paperbacks with my 10% discount, but you can also purchase them from your preferred retailer, of course! Finally, apologies for the repeating authors. I think it is clear who my favorites are!\\n\\nI hope you enjoy these book recommendations.\\n\\nWishing you lots of love (stories),\\n\\nKatherine\\n\\nMy Ultimate List of 5-Star Historical Romance Novel Recommendations\\n\\n(in no particular order)\\n\\nFive-Star Regency England Romance Novels\\n\\nA Lady Awakened by Cecilia Grant\\n\\nAverage Goodreads Rating: 3.71\\n\\nWhy I Loved It: Martha is the ultimate starchy heroine. She lives by black-and-white rules, and she believes them, too. I loved how she remained an archetypal \\\"frigid widow\\\" - complete with lectures on the benefits of education - yet also learned how to be more fluid in the world. On top of that, the ever-changing power dynamics in the steamy scenes makes this a masterclass in sexy romances for me!\\n\\nThe Synopsis:\\n\\nNewly widowed and desperate to protect her estate and beloved servants from her malevolent brother-in-law, Martha Russell conceives a daring plan. Or rather, a daring plan to conceive. After all, if she has an heir on the way, her future will be secured. Forsaking\"\n },\n {\n \"docid\": \"96202\",\n \"score\": 0.46788012981414795,\n \"snippet\": \"---\\ntitle: Rebecca (novel) - Wikipedia\\nauthor: Authority control databases\\ndate: 2004-12-09\\n---\\nname: Rebecca\\nauthor: Daphne du Maurier\\nlanguage: English\\ncountry: United Kingdom\\ngenre: Crime, Gothic, mystery, romance\\npublisher: Victor Gollancz Ltd\\npages: 446 pp\\noclc: 527505\\nrelease_date: 5 August 1938 Daily News (London) 2 August 1938\\n\\nRebecca is a 1938 Gothic novel by the English author Daphne du Maurier. It depicts an unnamed young woman who impetuously marries a wealthy widower, before discovering that both he and his household are haunted by the memory of his late first wife, the title character.\\n\\nA bestseller which has never gone out of print, Rebecca sold 2.8\\u00a0million copies between its publication in 1938 and 1965. It has been adapted numerous times for stage and screen, including a 1939 play by du Maurier herself, the film Rebecca (1940), directed by Alfred Hitchcock, which won the Academy Award for Best Picture, and the 2020 remake directed by Ben Wheatley for Netflix. The story has been adapted as a musical.\\n\\nThe novel is remembered especially for the character of Mrs. Danvers, the West Country estate Manderley, and its opening line: \\\"Last night, I dreamt I went to Manderley again.\\\"\\n\\nPlot\\n\\nWhile working as the companion to a rich American woman on holiday in Monte Carlo, the unnamed narrator, a na\\u00efve young woman in her early 20s, becomes acquainted with a wealthy Englishman, Maxim de Winter, a 42-year-old widower. After a fortnight of courtship, she agrees to marry him and, after the wedding and honeymoon, accompanies him to his mansion in Cornwall, the beautiful estate Manderley.\\n\\nMrs Danvers, the sinister housekeeper, was profoundly devoted to the first Mrs de Winter, Rebecca, who died in a sailing accident about a year before Maxim and the second Mrs de Winter met. She continually attempts to undermine the narrator psychologically, subtly suggesting to her that she will never attain the beauty, urbanity, and charm her predecessor possessed. When the narrator makes small requests, Mrs Danvers and the other staff describe how Rebecca ran M\"\n }\n]", "invoking_actions": [{"name": "search", "arguments": {}, "id": "d6174f08-75c7-4361-8b65-2d9cb4385b65", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.97135, "benchmark_cost": 0.0} +{"event": "action", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 12, "action": {"name": "get_document", "arguments": {"docid": "90027"}, "id": "43d536bb-1bba-46f1-a1d3-e69c07cc8e89", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 1.16775, "benchmark_cost": 0.0} +{"event": "observation", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 12, "observation": {"result": "{\n \"docid\": \"90027\",\n \"text\": \"---\\ntitle: Storm and Silence\\ndate: 2014-04-02\\n---\\n| 'Blast, blast, blast!'\\n\\nThis article is a stub and thus, inadequate. Help Storm and Silence Wiki by expanding it. |\\n\\nStorm and Silence is the first novel in the eponymous series written by Robert Thier. It is free to read on the online community Wattpad and the Radish Fiction Mobile App. The first chapter was uploaded onto Wattpad on April 2014 and the novel was published electronically and in paperback on March 2016. It won the 2015 Award for Story of the Year and currently has a massive fanbase, with over 100 million reads on Wattpad.\\n\\nSynopsis[]\\n\\nWattpad Version[]\\n\\n\\\"It is your choice,\\\" he said, stepping so close to me that our lips were almost touching.\\n\\n\\\"Either do what I say - or get another job.\\\"\\n\\nMy heart stood still as I gazed up into his deep, dark, dangerous eyes...\\n\\nIn a world where women's only role in life is to sit at home and look pretty, Lilly is determined to fight for her freedom. There's only one problem: a powerful man blocking her way. [1]\\n\\nRadish/Published Version[]\\n\\nFreedom \\u2013 that is what Lilly Linton wants most in life. Not marriage, not a brood of squalling brats, and certainly not love, thank you very much!\\n\\nBut freedom is a rare commodity in 19th-century London, where girls are expected to spend their lives sitting at home, fully occupied with looking pretty. Lilly is at her wits' end \\u2013 until a chance encounter with a dark, dangerous and powerful stranger changes her life forever...[2]\\n\\nPlot[]\\n\\nTBA\\n\\nChapters[]\\n\\nWattpad Version[]\\n\\n- Arrested for Good Manners\\n\\n- Ape Bobby\\n\\n- Who He Really Is\\n\\n- Sweet and Solid\\n\\n- Driving Me Wild(ly)\\n\\n- Empire House\\n\\n- His Indecent Demands\\n\\n- Inventing a Sibling and Getting Poked in the Eye\\n\\n- File Fight\\n\\n- The Worst Fate Imaginable\\n\\n- The Dragon's Den\\n\\n- Practicing Impertinence\\n\\n- Ballroom Battle\\n\\n- The Sins of Mr Rikkard Ambrose\\n\\n- It gets mushy-gushy\\n\\n- Unsuitable Suitors\\n\\n- Return to the Game\\n\\n- The Peril of Flowers\\n\\n- The Discovery\\n\\n- Threats and Secrets\\n\\n- I Defend my Honour, More's the Pity\\n\\n- My All-important Task\\n\\n- Little Ifrit\\n\\n- The Beauty and the Vegetables\\n\\n- I Go Dress-Shopping\\n\\n- My Little Secret\\n\\n- The Thief\\n\\n- Improving My Skirt\\n\\n- The Key to Him\\n\\n- I Make Lieutenant-Pancake\\n\\n- Prospects of Matrimonial Misery\\n\\n- More Misery Behind the Bush\\n\\n|\\n\\n- What to Do with Pink?\\n\\n- Going to the Room that Doesn't Exist\\n\\n- Problems? What Problems?\\n\\n- Sisters' Battles\\n\\n- Ambrosian Waste Disposal Squad\\n\\n- The Adversary\\n\\n- Pink Espionage\\n\\n- Dysfunctional Dismissal\\n\\n- To Meet without Trousers\\n\\n- In Tow\\n\\n- Twice Surprise\\n\\n- A Duel of Eyes\\n\\n- To Dance with him\\n\\n- Secret Plans and Politics\\n\\n- The Message Lock\\n\\n- Woes of Love\\n\\n- And a few more Woes of Love\\n\\n- Threats and Decisions\\n\\n- The Great Hunt of Green Park\\n\\n- Pinching and Planning\\n\\n- On Dates\\n\\n- Bloody Work\\n\\n- My lies run away with me\\n\\n- The Importance of Being Nice\\n\\n- Am I a Chimpanzee?\\n\\n- The Speech\\n\\n- The Other Speech\\n\\n- I Realize I Danced with a Criminal Mastermind\\n\\n- Cozy Little Coach Ride\\n\\n- I Mash and Bend Myself\\n\\n- I Bend Myself A Little Further\\n\\n- Napoleon and all the Little Piggies\\n\\n- Fighting Spirit\\n\\n|\\n\\n- Hallucination Manicure\\n\\n- Unluckily Unlocked\\n\\n- Looking for Truffles and Butterflies\\n\\n- Seeing Stars\\n\\n- A Trace of Fire Brings the Winter\\n\\n- I Polish My Housebreaking Skills\\n\\n- Unreal Dream of a Really Wonderful Nightmare\\n\\n- Victory Party\\n\\n- Sisterly Love\\n\\n- Biting Metaphorical Heads\\n\\n- Secrets of the Toilet\\n\\n- Different Sorts of Silence\\n\\n- Competition\\n\\n- A Waist of Tigers\\n\\n- Behind the Mask\\n\\n- Trapped\\n\\n- Pneumatic Freedom\\n\\n- A Man's Work\\n\\n- Bifurcated\\n\\n- Lion's Den\\n\\n- Lion's Jaws\\n\\n- Nemesis\\n\\n- Danger! Explosive Cargo!\\n\\n- Lessons in Power\\n\\n- A Special Person\\n\\n- Isle Marbeau\\n\\n- Mine and Yours\\n\\n- The Tortoise and the other Tortoise and no Hare\\n\\n- Shots in the Dark\\n\\n- Urania\\n\\n- Rising Waves\\n\\n- Man and Woman\\n\\n|\\n\\nRadish/Published Version[]\\n\\n- Arrested for Good Manners\\n\\n- Ape Bobby\\n\\n- Who He Really Is\\n\\n- Sweet and Solid\\n\\n- Empire House\\n\\n- His Indecent Demands\\n\\n- File Fight\\n\\n- The Worst Fate Imaginable\\n\\n- Practicing Impertinence\\n\\n- The Sins of Mr Rikkard Ambrose\\n\\n- Unsuitable Suitors\\n\\n- Return to the Game\\n\\n- The Discovery\\n\\n- I Defend my Honour, More's the Pity\\n\\n- Little Ifrit\\n\\n- I Go Dress-Shopping\\n\\n- The Thief\\n\\n- Making Lieutenant-Pancake\\n\\n- What to Do with Pink?\\n\\n- Problems? What Problems?\\n\\n|\\n\\n- Ambrosian Waste Disposal Squad\\n\\n- The Adversary\\n\\n- Dysfunctional Dismissal\\n\\n- Twice Surprised\\n\\n- Duelling on the Dance Floor\\n\\n- Secret Plans and Politics\\n\\n- Woes of Love\\n\\n- The Great Hunt of Green Park\\n\\n- Bloody Work\\n\\n- The Importance of Being Nice\\n\\n- Am I a Chimpanzee?\\n\\n- Memorable Speeches\\n\\n- I Realize I Danced with a Criminal Mastermind\\n\\n- I Mash and Bend Myself\\n\\n- Napoleon and all the Little Piggies\\n\\n- Fighting Spirit\\n\\n- Hallucination Manicure\\n\\n- Looking for Truffles and Butterflies\\n\\n- Seeing Stars\\n\\n|\\n\\n- A Trace of Fire Brings the Winter\\n\\n- Unreal Dream of a Really Wonderful Nightmare\\n\\n- Victory Party?\\n\\n- Toilet Secrets\\n\\n- Different Sorts of Silence\\n\\n- A Waist of Tigers\\n\\n- Behind the Mask\\n\\n- Trapped\\n\\n- A Man's Work\\n\\n- Bifurcated\\n\\n- Lion's Den\\n\\n- Danger! Explosive Cargo!\\n\\n- Lessons in Power\\n\\n- \\u00cele Marbeau\\n\\n- Mine and Yours\\n\\n- The Tortoise and the other Tortoise and no Hare\\n\\n- Shots in the Dark\\n\\n- Rising Waves\\n\\n- Man and Woman\\n\\n|\\n\\nAmbrose POV Chapter:\\n\\n- None of Your Business\\n\\n(Available in published version only)\\n\\nCharacters[]\\n\\n- Flora Milton\\n\\n- Gertrude Linton\\n\\n- Lisbeth Linton\\n\\n- Maria Linton\\n\\n- Anne Linton\\n\\n- Thomas Warren\\n\\n- Edgar Stone\\n\\n- Hieronymous Pearson\\n\\n- Eloise Hamilton\\n\\n- Lt Ellingham\\n\\n|\"\n}", "invoking_actions": [{"name": "get_document", "arguments": {}, "id": "43d536bb-1bba-46f1-a1d3-e69c07cc8e89", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 1.16775, "benchmark_cost": 0.0} +{"event": "action", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 13, "action": {"name": "search", "arguments": {"query": "\"Storm and Silence\" Robert Thier Lilly Ambrose businessman document encrypted sea storm rescue"}, "id": "b1d52267-9cbb-4411-b1c9-a1377584838e", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 1.3779150000000002, "benchmark_cost": 0.0} +{"event": "observation", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 13, "observation": {"result": "[\n {\n \"docid\": \"90027\",\n \"score\": 0.5981267690658569,\n \"snippet\": \"---\\ntitle: Storm and Silence\\ndate: 2014-04-02\\n---\\n| 'Blast, blast, blast!'\\n\\nThis article is a stub and thus, inadequate. Help Storm and Silence Wiki by expanding it. |\\n\\nStorm and Silence is the first novel in the eponymous series written by Robert Thier. It is free to read on the online community Wattpad and the Radish Fiction Mobile App. The first chapter was uploaded onto Wattpad on April 2014 and the novel was published electronically and in paperback on March 2016. It won the 2015 Award for Story of the Year and currently has a massive fanbase, with over 100 million reads on Wattpad.\\n\\nSynopsis[]\\n\\nWattpad Version[]\\n\\n\\\"It is your choice,\\\" he said, stepping so close to me that our lips were almost touching.\\n\\n\\\"Either do what I say - or get another job.\\\"\\n\\nMy heart stood still as I gazed up into his deep, dark, dangerous eyes...\\n\\nIn a world where women's only role in life is to sit at home and look pretty, Lilly is determined to fight for her freedom. There's only one problem: a powerful man blocking her way. [1]\\n\\nRadish/Published Version[]\\n\\nFreedom \\u2013 that is what Lilly Linton wants most in life. Not marriage, not a brood of squalling brats, and certainly not love, thank you very much!\\n\\nBut freedom is a rare commodity in 19th-century London, where girls are expected to spend their lives sitting at home, fully occupied with looking pretty. Lilly is at her wits' end \\u2013 until a chance encounter with a dark, dangerous and powerful stranger changes her life forever...[2]\\n\\nPlot[]\\n\\nTBA\\n\\nChapters[]\\n\\nWattpad Version[]\\n\\n- Arrested for Good Manners\\n\\n- Ape Bobby\\n\\n- Who He Really Is\\n\\n- Sweet and Solid\\n\\n- Driving Me Wild(ly)\\n\\n- Empire House\\n\\n- His Indecent Demands\\n\\n- Inventing a Sibling and Getting Poked in the Eye\\n\\n- File Fight\\n\\n- The Worst Fate Imaginable\\n\\n- The Dragon's Den\\n\\n- Practicing Impertinence\\n\\n- Ballroom Battle\\n\\n- The Sins of Mr Rikkard Ambrose\\n\\n- It gets mushy-gushy\\n\\n- Unsuitable Suitors\\n\\n- Return to the Game\\n\\n- The Peril of Flowers\\n\\n- The\"\n },\n {\n \"docid\": \"25654\",\n \"score\": 0.5688382387161255,\n \"snippet\": \"---\\ntitle: Storm and Silence (Book Series)\\ndate: 2015-08-12\\n---\\nStorm and Silence is a historical fiction and romance book series written by Robert Thier. As of 2018, there are eight books in the entire series canonically. The series has gained extensive popularity online, with over one hundred million cumulative reads on the online community Wattpad and over six million reads on the Radish Fiction App.\\n\\nThe story follows the adventures of Lilly Linton, a fiery feminist living in 19th-century London, determined to earn her freedom and independence when her life is turned upside down after a chance encounter with the ruthless financial business magnate, Mr Ambrose.\\n\\nBooks\\n\\nMain series\\n\\nCurrently, there are six books in the main series. For each book initially, the chapters are individually uploaded on a weekly basis onto Radish and then Wattpad. When completed, they are edited and published as an individual book both electronically and in paperback. However, the first book is an exception to this as chapters were uploaded to Wattpad only at the time and then the book was self-published since the author did not start using Radish until the second book.\\n\\nThe following is a chronological list of the main books formatted as: Title (year initially uploaded online | year officially published).\\n\\n- Storm and Silence (2014 | 2016)\\n\\n- In the Eye of the Storm (2016 | 2016)\\n\\n- Silence is Golden (2016 | 2017)\\n\\n- Silence Breaking (2017 | 2018)\\n\\n- Hunting for Silence (2018 | TBA)\\n\\n- Storm of Bells (2018 | TBA)\\n\\n- New Storm Rising (2020 | TBA)\\n\\n- Storm over the Caribbean\\n\\n- Silence no More\\n\\nPrequel\\n\\nThe prequel takes place shortly before the beginning of the first book in the main series. It is a stand-alone novella that was commissioned by Radish Fiction and is exclusively on the mobile app.\\n\\n- Before the Storm (2016 | \\u2014)\\n\\nSide story\\n\\nThe following work takes place in between the second and third book of the main series. It was written in thanks from the author to the fanbase for voting for the first book from the main series in the Romance category for the 2016 Goodreads Choice Awards.\\n\\n- Silent Night (2016 | \\u2014\"\n },\n {\n \"docid\": \"72764\",\n \"score\": 0.5286663174629211,\n \"snippet\": \"---\\ntitle: Overboard!: A True Blue-water Odyssey of Disaster and Survival Hardcover \\u2013 March 16, 2010\\nauthor: Michael J Tougias\\ndate: 2010-03-16\\n---\\n-24% $18.15$18.15\\n\\nDelivery Sunday, June 15\\n\\nShips from: Amazon Sold by: Bookdream\\n\\n$13.79$13.79\\n\\nDelivery Thursday, June 19\\n\\nShips from: Amazon Sold by: Zoom Books Company\\n\\nReturn this item for free\\n\\nFree returns are available for the shipping address you chose. You can return the item for any reason in new and unused condition: no return shipping charges.\\n\\nLearn more about free returns.- Go to your orders and start the return\\n\\n- Select your preferred free shipping option\\n\\n- Drop off and leave!\\n\\nSorry, there was a problem.\\n\\nThere was an error retrieving your Wish Lists. Please try again.Sorry, there was a problem.\\n\\nList unavailable.Download the free Kindle app and start reading Kindle books instantly on your smartphone, tablet, or computer - no Kindle device required.\\n\\nRead instantly on your browser with Kindle for Web.\\n\\nUsing your mobile phone camera - scan the code below and download the Kindle app.\\n\\nAudible sample\\n\\nFollow the author\\n\\nOK\\n\\nOverboard!: A True Blue-water Odyssey of Disaster and Survival Hardcover \\u2013 March 16, 2010\\n\\nPurchase options and add-ons\\n\\nIn May 2005, Tom Tighe, captain of a forty-five-foot-long sailboat named the Almeisan, and his first mate, Loch Reidy, welcomed three new crewmembers for a five-day voyage from Connecticut across the blue waters of the Gulf Stream to sun-drenched Bermuda. The new crew included forty-six-year-old Kathy Gilchrist, seventy-year-old Ron Burd, and thirty-four-year-old Chris Ferrer. Although Tighe had made the trip forty-eight times, with Reidy accompanying him on twenty of those voyages, the rest of the crew had joined to learn more about offshore sailing.\\n\\nFour days into the voyage, an enormous storm struck, sweeping two of the crew into the towering sea. The remaining crewmembers managed to stay aboard the vessel as it was slowly torn apart by the rampaging ocean. Overboard! follows the simultaneous desperate struggles of both those still on the boat and those fighting for their lives in the sea.\\n\\nThe Coast Guard, alerted to the Almeisan's\"\n },\n {\n \"docid\": \"71659\",\n \"score\": 0.5275611877441406,\n \"snippet\": \"---\\ntitle: REVIEW: Storm and Silence by Robert Thier\\nauthor: Nikka\\ndate: 2016-04-03\\n---\\nP A P E R & T R E E S\\nA Book Blog\\n\\nREVIEW: Storm and Silence by Robert Thier\\nA copy of this book was provided by the authorin exchange for an honest review. This does not affect my opinion of the book or the content of my review.\\n\\nSeries: Storm and Silence #1Publication Date: March 19 2016 by Robert Thier\\nGenres: Romance, Historical Fiction\\nNumber of Pages: 596\\nMy Rating: \\u2605\\u2605\\u2605\\u2729\\u2729\\nAmazon // Book Depository // iBooksGoodreads\\n\\nFreedom \\u2013 that is what Lilly Linton wants most in life. Not marriage, not a brood of squalling brats, and certainly not love, thank you very much!\\nBut freedom is a rare commodity in 19th-century London, where girls are expected to spend their lives sitting at home, fully occupied with looking pretty. Lilly is at her wits' end \\u2013 until a chance encounter with a dark, dangerous and powerful stranger changes her life forever\\u2026\\nEnter the world of Mr Rikkard Ambrose, where the only rule is: Knowledge is power is time is money!\\n\\nThe story is set in 19th-century London where women, although dressed beautifully and often treated as fragile flowers, do not have the liberty to pursue their own interests. From education to marriage to rights, women were oppressed and seen as naive and incapable of living on their own.\\nAs Tiffany Stelle retold, \\\"She was expected to keep the house clean, cook the meals, raise the children, decorate the house, and keep her children and husband on the moral high ground. When a husband came home from work he was expecting a smile on his wife, who is dressed perfectly, a house that is spotless and bright, and a forgiving happy environment that would lure him into wanting to return home each day.\\\"\\nBut for Lilly Linton, this is not the way things are supposed to be. She is a suffragette, a feminist, and she needs freedom as much as the next guy needs his daily dose of vanity. So she did what a feminist woman did during the Victorian era\\u2014she pretended to be a man. But unlike any other woman, she was offered a job and she took it, for this could be the key to the freedom she's longing for.\\nThis book\"\n },\n {\n \"docid\": \"74026\",\n \"score\": 0.4970148205757141,\n \"snippet\": \"---\\ntitle: Buying and sending eBooks to others\\nauthor: Sebastian Junger\\ndate: 1997-05-17\\n---\\nLearn more\\n\\nThese promotions will be applied to this item:\\n\\nSome promotions may be combined; others are not eligible to be combined with other offers. For details, please see the Terms & Conditions associated with these promotions.\\n\\n- Highlight, take notes, and search in the book\\n\\n- In this edition, page numbers are just like the physical edition\\n\\nYour Memberships & Subscriptions\\n\\nDownload the free Kindle app and start reading Kindle books instantly on your smartphone, tablet, or computer - no Kindle device required.\\n\\nRead instantly on your browser with Kindle for Web.\\n\\nUsing your mobile phone camera - scan the code below and download the Kindle app.\\n\\nAudible sample\\n\\nFollow the author\\n\\nOK\\n\\nThe Perfect Storm: A True Story of Men Against the Sea Media tie-in Edition, Kindle Edition\\n\\n\\\"There is nothing imaginary about Junger's book; it is all terrifyingly, awesomely real.\\\" \\u2014Los Angeles Times\\n\\nIt was the storm of the century, boasting waves over one hundred feet high\\u2014a tempest created by so rare a combination of factors that meteorologists deemed it \\\"the perfect storm.\\\" In a book that has become a classic, Sebastian Junger explores the history of the fishing industry, the science of storms, and the candid accounts of the people whose lives the storm touched. The Perfect Storm is a real-life thriller that makes us feel like we've been caught, helpless, in the grip of a force of nature beyond our understanding or control.\\n\\nWinner of the American Library Association's 1998 Alex Award.\\n\\n- ISBN-13978-0393076615\\n\\n- EditionMedia tie-in\\n\\n- PublisherW. W. Norton & Company\\n\\n- Publication dateMay 17, 1997\\n\\n- LanguageEnglish\\n\\n- File size1.7 MB\\n\\nSee all supported devices\\n\\nKindle E-Readers\\n\\n- Kindle Paperwhite (5th Generation)\\n\\n- Kindle (11th Generation, 2024 Release)\\n\\n- Kindle Paperwhite\\n\\n- Kindle\\n\\n- Kindle Paperwhite (12th Generation)\\n\\n- Kindle Voyage\\n\\n- All New Kindle E-reader (11th Generation)\\n\\n- All New Kindle E-reader\\n\\n- Kindle Oasis\\n\\n- Kindle Oasis (10th Generation)\\n\\n- Kindle Paperwhite (10th Generation)\\n\\n- Kindle (10th Generation)\\n\\n- Kindle Scribe\"\n }\n]", "invoking_actions": [{"name": "search", "arguments": {}, "id": "b1d52267-9cbb-4411-b1c9-a1377584838e", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 1.3779150000000002, "benchmark_cost": 0.0} +{"event": "action", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 14, "action": {"name": "search", "arguments": {"query": "\"Storm and Silence\" Robert Thier plot document theft accusation schemer cipher file villain"}, "id": "a534f3c0-a6da-462d-9fb6-87eaaa30e981", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 1.604945, "benchmark_cost": 0.0} +{"event": "observation", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 14, "observation": {"result": "[\n {\n \"docid\": \"90027\",\n \"score\": 0.4984514117240906,\n \"snippet\": \"---\\ntitle: Storm and Silence\\ndate: 2014-04-02\\n---\\n| 'Blast, blast, blast!'\\n\\nThis article is a stub and thus, inadequate. Help Storm and Silence Wiki by expanding it. |\\n\\nStorm and Silence is the first novel in the eponymous series written by Robert Thier. It is free to read on the online community Wattpad and the Radish Fiction Mobile App. The first chapter was uploaded onto Wattpad on April 2014 and the novel was published electronically and in paperback on March 2016. It won the 2015 Award for Story of the Year and currently has a massive fanbase, with over 100 million reads on Wattpad.\\n\\nSynopsis[]\\n\\nWattpad Version[]\\n\\n\\\"It is your choice,\\\" he said, stepping so close to me that our lips were almost touching.\\n\\n\\\"Either do what I say - or get another job.\\\"\\n\\nMy heart stood still as I gazed up into his deep, dark, dangerous eyes...\\n\\nIn a world where women's only role in life is to sit at home and look pretty, Lilly is determined to fight for her freedom. There's only one problem: a powerful man blocking her way. [1]\\n\\nRadish/Published Version[]\\n\\nFreedom \\u2013 that is what Lilly Linton wants most in life. Not marriage, not a brood of squalling brats, and certainly not love, thank you very much!\\n\\nBut freedom is a rare commodity in 19th-century London, where girls are expected to spend their lives sitting at home, fully occupied with looking pretty. Lilly is at her wits' end \\u2013 until a chance encounter with a dark, dangerous and powerful stranger changes her life forever...[2]\\n\\nPlot[]\\n\\nTBA\\n\\nChapters[]\\n\\nWattpad Version[]\\n\\n- Arrested for Good Manners\\n\\n- Ape Bobby\\n\\n- Who He Really Is\\n\\n- Sweet and Solid\\n\\n- Driving Me Wild(ly)\\n\\n- Empire House\\n\\n- His Indecent Demands\\n\\n- Inventing a Sibling and Getting Poked in the Eye\\n\\n- File Fight\\n\\n- The Worst Fate Imaginable\\n\\n- The Dragon's Den\\n\\n- Practicing Impertinence\\n\\n- Ballroom Battle\\n\\n- The Sins of Mr Rikkard Ambrose\\n\\n- It gets mushy-gushy\\n\\n- Unsuitable Suitors\\n\\n- Return to the Game\\n\\n- The Peril of Flowers\\n\\n- The\"\n },\n {\n \"docid\": \"25654\",\n \"score\": 0.4966529309749603,\n \"snippet\": \"---\\ntitle: Storm and Silence (Book Series)\\ndate: 2015-08-12\\n---\\nStorm and Silence is a historical fiction and romance book series written by Robert Thier. As of 2018, there are eight books in the entire series canonically. The series has gained extensive popularity online, with over one hundred million cumulative reads on the online community Wattpad and over six million reads on the Radish Fiction App.\\n\\nThe story follows the adventures of Lilly Linton, a fiery feminist living in 19th-century London, determined to earn her freedom and independence when her life is turned upside down after a chance encounter with the ruthless financial business magnate, Mr Ambrose.\\n\\nBooks\\n\\nMain series\\n\\nCurrently, there are six books in the main series. For each book initially, the chapters are individually uploaded on a weekly basis onto Radish and then Wattpad. When completed, they are edited and published as an individual book both electronically and in paperback. However, the first book is an exception to this as chapters were uploaded to Wattpad only at the time and then the book was self-published since the author did not start using Radish until the second book.\\n\\nThe following is a chronological list of the main books formatted as: Title (year initially uploaded online | year officially published).\\n\\n- Storm and Silence (2014 | 2016)\\n\\n- In the Eye of the Storm (2016 | 2016)\\n\\n- Silence is Golden (2016 | 2017)\\n\\n- Silence Breaking (2017 | 2018)\\n\\n- Hunting for Silence (2018 | TBA)\\n\\n- Storm of Bells (2018 | TBA)\\n\\n- New Storm Rising (2020 | TBA)\\n\\n- Storm over the Caribbean\\n\\n- Silence no More\\n\\nPrequel\\n\\nThe prequel takes place shortly before the beginning of the first book in the main series. It is a stand-alone novella that was commissioned by Radish Fiction and is exclusively on the mobile app.\\n\\n- Before the Storm (2016 | \\u2014)\\n\\nSide story\\n\\nThe following work takes place in between the second and third book of the main series. It was written in thanks from the author to the fanbase for voting for the first book from the main series in the Romance category for the 2016 Goodreads Choice Awards.\\n\\n- Silent Night (2016 | \\u2014\"\n },\n {\n \"docid\": \"84371\",\n \"score\": 0.4604397714138031,\n \"snippet\": \"---\\ntitle: Author Interview: Rob Thier Talks 'Storm and Silence'\\nauthor: Amber Topping\\ndate: 2024-12-13\\n---\\nToday, we're excited to have author Rob Thier (known to his fans as \\\"Sir Rob\\\") of 'Storm and Silence' and 'The Robber Knight Series' here to discuss his background and latest book!\\n\\nThank you for agreeing to do this interview. Can you tell us a little bit about yourself and how you came to be a writer?\\n\\nI must admit I started writing so early in life that I don't really remember when or how. Maybe when I was nine or ten years old? But it didn't develop into anything serious until I discovered online writing platforms like Wattpad and started studying at an English university a few years ago. Unlike my failed writing attempts in my native language, German, here for the first time I had an opportunity to share my writing with a big audience, ask feedback and gather experience. And my university studies gave me the practice I needed to perfect my grasp on the English language. From there it was just a small step to deciding that I wanted to be a professional writer, and that has been my dream ever since.\\n\\nHow does being a historian affect the way you write stories?\\n\\nIt has made me a little bit of a stickler for accuracy. I do a lot of research for my stories. Even when it is a contemporary story, I want to make sure that the characters behave in a realistic way. Even if I were to write a fantasy story, I would research various mythologies and create a world that is in some way related to real human ideas and conceptions out there in the world. It makes for a very interesting writing process because during the research you always discover things that you never knew before.\\n\\nCongratulations on your latest story, Storm and Silence. For those who aren't familiar, can you explain what it's about?\\n\\nThe story follows the adventures of 19-year-old feminist Lilly and her budding romance with the powerful and ruthless business mogul Rikkard Ambrose. A firm believer in the equality of men and women, Lilly is forced to dress up as a man in order to work for Mr. Ambrose and achieve her independence. The two of them butt heads all the time, but when an important document is stolen from Mr. Ambrose's safe, and he has fought a battle of wits and intrigue with his arch-enemy Lord Dalgliesh, she stands by him through thick and\"\n },\n {\n \"docid\": \"60123\",\n \"score\": 0.45862993597984314,\n \"snippet\": \"---\\ntitle: Thief II - Wikipedia\\nauthor: Authority control databases National France BnF data\\ndate: 2005-07-10\\n---\\ntitle: Thief II: The Metal Age\\ndeveloper: Looking Glass Studios\\npublisher: Eidos Interactive\\ndirector: Steve Pearsall\\ndesigner: Tim StellmachRandy Smith\\nprogrammer: Alex DuranWilliam FarquharPat McElhatton\\nartist: Mark Lizotte\\ncomposer: Eric Brosius\\nseries: Thief\\nengine: Dark Engine\\nplatforms: Windows\\nreleased: NA March 23, 2000 EU March 31, 2000\\ngenre: Stealth\\nmodes: Single-player\\n\\nThief II: The Metal Age is a 2000 stealth video game developed by Looking Glass Studios and published by Eidos Interactive in March 2000. Like its predecessor Thief: The Dark Project, the game follows Garrett, a master thief who works in and around a steampunk metropolis called the City. The player assumes the role of Garrett as he unravels a conspiracy related to a new religious sect. Garrett takes on missions such as burglaries and frameups, while trying to avoid detection by guards and automated security.\\n\\nThief II was designed to build on the foundation of its predecessor. In response to feedback from players of Thief, the team placed a heavy focus on urban stealth in the sequel, and they minimized the use of monsters and maze-like levels. The game was made with the third iteration of the Dark Engine, which had been used previously to develop Thief and System Shock 2. Thief II was announced at the 1999 Electronic Entertainment Expo, as part of an extended contract between Looking Glass and Eidos to release games in the Thief series. Looking Glass neared bankruptcy as the game was developed, and the company was kept running by advances from Eidos.\\n\\nThief II received positive reviews from critics, and its initial sales were stronger than those of its predecessor. However, the game's royalties were processed slowly, which compounded Looking Glass's financial troubles. As a result, the company closed in May 2000, with plans for Thief III cancelled. The third game in the series, entitled Thief: Deadly Shadows, was developed by Ion Storm and published by Eidos in 2004. Thief 2X: Shadows of the Metal Age, a widely praised expansion mod for Thief II, was released in 20\"\n },\n {\n \"docid\": \"13697\",\n \"score\": 0.45751625299453735,\n \"snippet\": \"---\\ntitle: Can you agree that Mr. Hands is the most evil and horrible character in Cyberpunk?\\ndate: 2025-03-28\\n---\\nNot that Hands horrifies me...in some ways I even like him. But...for the sake of fairness, I'll put my purely subjective view of this man \\\"on the court of public opinion\\\" )\\n\\nIt's time to voice our thoughts on Night City's main villain - Wade Bleecker aka Mr. Hands! Let's start the discussion with the intercepted correspondence between Hands and Lt. Col. Bennett on Hands' laptop in his office:\\n\\nAnd some people believe that Mr. Hands is \\\"just a fixer, just one of them\\\"...\\n\\nOk, what do we see here?\\n\\n1. Cynosure is one of the most secret projects of the NUSA and Militech (as well as Netwatch!), a project that is personally supervised by President Rosalind Myers and which is generally the key to the survival of the NUSA in the fight against \\\"Arasaka\\\" and their rival technologies (Relic\\\\Soulkiller).\\n\\nHow many other fixers in the game are aware of the existence of this project?\\n\\nWho are of these \\\"fixers\\\" and under what conditions they can access this level of information?\\n\\nAnd how many of them will be left alive after, given Netwacht's methods of preventing information leaks (see the gig \\\"Shot from both sides\\\").\\n\\nNot only does Hands have the necessary security clearance, but he also allows himself to operate on this information about the project in a conversation with a third party - in this case, Colonel Hansen's first deputy. Who can afford it?\\n\\nI'll be brief here: Hands is not a fixer. It is impossible for a fixer to participate in a project of this level.\\n\\n2. Hands knows the name and purpose of the Cynosure project. Concerned about maintaining the secrecy of the project. Aware of the existence of \\\"underground bunkers\\\". Is it logical to assume he knows everything else? - What exactly is the tech hidden underground, and what are the risks of trying to access it? I believe the answer is positive here.\\n\\nNext: Message log from Shuttle 1 (probably Songbird's workstation).\\n\\nWhat do we have here?\\n\\n1. The FIA was interested in the attempted theft of Relic's chip even before the immediate events in Kompeki Plaza. So Mi lies to V when she says that he was chosen at random from all the mercenaries simply because his\"\n }\n]", "invoking_actions": [{"name": "search", "arguments": {}, "id": "a534f3c0-a6da-462d-9fb6-87eaaa30e981", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 1.604945, "benchmark_cost": 0.0} +{"event": "action", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 15, "action": {"name": "get_document", "arguments": {"docid": "84371"}, "id": "2ce54757-7e7f-4905-a574-98e96a7f43e3", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 1.8492050000000002, "benchmark_cost": 0.0} +{"event": "observation", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 15, "observation": {"result": "{\n \"docid\": \"84371\",\n \"text\": \"---\\ntitle: Author Interview: Rob Thier Talks 'Storm and Silence'\\nauthor: Amber Topping\\ndate: 2024-12-13\\n---\\nToday, we're excited to have author Rob Thier (known to his fans as \\\"Sir Rob\\\") of 'Storm and Silence' and 'The Robber Knight Series' here to discuss his background and latest book!\\n\\nThank you for agreeing to do this interview. Can you tell us a little bit about yourself and how you came to be a writer?\\n\\nI must admit I started writing so early in life that I don't really remember when or how. Maybe when I was nine or ten years old? But it didn't develop into anything serious until I discovered online writing platforms like Wattpad and started studying at an English university a few years ago. Unlike my failed writing attempts in my native language, German, here for the first time I had an opportunity to share my writing with a big audience, ask feedback and gather experience. And my university studies gave me the practice I needed to perfect my grasp on the English language. From there it was just a small step to deciding that I wanted to be a professional writer, and that has been my dream ever since.\\n\\nHow does being a historian affect the way you write stories?\\n\\nIt has made me a little bit of a stickler for accuracy. I do a lot of research for my stories. Even when it is a contemporary story, I want to make sure that the characters behave in a realistic way. Even if I were to write a fantasy story, I would research various mythologies and create a world that is in some way related to real human ideas and conceptions out there in the world. It makes for a very interesting writing process because during the research you always discover things that you never knew before.\\n\\nCongratulations on your latest story, Storm and Silence. For those who aren't familiar, can you explain what it's about?\\n\\nThe story follows the adventures of 19-year-old feminist Lilly and her budding romance with the powerful and ruthless business mogul Rikkard Ambrose. A firm believer in the equality of men and women, Lilly is forced to dress up as a man in order to work for Mr. Ambrose and achieve her independence. The two of them butt heads all the time, but when an important document is stolen from Mr. Ambrose's safe, and he has fought a battle of wits and intrigue with his arch-enemy Lord Dalgliesh, she stands by him through thick and thin, and the two of them grow closer, until their relationship finally heats up.\\n\\nRELATED YA Advanced Book Review: Storm and Silence \\u2013 Bridget Jones Meets James Bond\\n\\nCan you share some insight behind the story and your main characters Lilly and Mr. Ambrose?\\n\\nOriginally, the story grew out of my study of 19th-century suffragism and Chartism. I read about a chartist demonstration where several dozen people were beaten to death by policeman for wanting the vote, and I thought \\u2013 if that's what it was like for men, how much worse must it have been for the women, who, back then, had no chance to learn how to defend themselves?\\n\\nLilly developed as a mix of my favorite female fictional characters and some leading early-day feminists and suffragists. She is strong, quirky, determined, and will let nothing and no one stand in the way of her dreams. I needed a male character who would be strong enough to stand up to her and clash with her occasionally. A Victorian industrialist-financier type, utterly ruthless, chauvinistic and stingy, seemed the ideal contrast to her, and an interesting character to have as a 'hero.' Thus, Lilly and Mr. Ambrose were developed.\\n\\nSomething I loved about Storm and Silence was the connection to the suffragette movement. What specifically drew you to write about this particular time period?\\n\\nIt was the first period in which a feminist female character could, with historical accuracy, be portrayed as fighting for her rights. True, there were exceptional women before then who ventured into the male world, such as Joan of Arc, but mostly women distinguished themselves in areas that were traditionally associated with their stereotypical gender role. The thing about the Victorian era that attracted me was that during this period, women began for the first time to speak up against the injustices in their lives in greater numbers. It was an era of great change, and that fascinated me.\\n\\nWhat are some of the challenges you faced finishing the book?\\n\\nGetting Lilly and Ambrose to relent, and to grow closer. They can be quite stubborn characters in that regard. It also wasn't easy to give an accurate description of some of the parts of 19th century London. It was rather difficult to do a subject on English history here from Germany. But it was well worth it in the end.\\n\\nCan you tell us a little about some of the other books you've written?\\n\\nThe only other book of mine currently published is the first volume of The Robber Knight Series, following the adventures of the mysterious, bewitched knight Sir Reuben and his romance with Lady Ayla von Luntberg. Sir Reuben is a fallen knight, the medieval version of a bad boy who has renounced all notions of honor and makes his living by robbing people. His motto: \\\"I steal from the rich to give to myself.\\\" But when Lady Ayla saves his life, he feels bound to help her defend her lands against the approaching army of the Margrave von Falkenstein, and the two of them become entangled in a dangerous romance.\\n\\nI do have other stories as drafts on Wattpad.com, the online writing platform where I first started making my writing public, and I'm planning to publish more of them as paperbacks and ebooks soon, including the sequel to Storm and Silence, which is currently in the works.\\n\\nAre there any writers who came before that influence your own writing style?\\n\\nYes, definitely. The top four writers who influenced me probably are Terry Pratchett, Meg Cabot, Roald Dahl and Jane Austen. A very varied collection \\u2013 they write (or wrote) everything from humorous fantasy over romance to children's novels. But what they all have in common is a good dose of humor which I have tried to incorporate into my own works.\\n\\nWhat writing project are you working on next?\\n\\nOf course, there is the Storm and Silence sequel, In the Eye of the Storm. But apart from that, I am working on developing ideas for a new project, something that will give me a chance to explore my penchant for dark humor. If you enjoyed the humor in Storm and Silence, you definitely have things to look forward to!\\n\\nWhat's the best way for readers to follow you and your projects?\\n\\nI regularly post news about my writing projects on social media, including Facebook, Twitter, and Google+. Here are the links to my pages:\\n\\nFacebook: \\n\\nTwitter: TheSirRob at \\n\\nGoogle+: \\n\\nI also post drafts of my stories on Wattpad and Radish Fiction. Anyone who wants to be the first to get their hands on new chapters can do so online with just a few clicks. Here's my Wattpad profile: \\n\\nAnd here's the download page for the Radish Fiction App, which gets somewhat earlier updates: \\n\\nHave fun reading! \\ud83d\\ude42\\n\\nRELATED:\\n\\nYou can read our other interviews HERE.\\n\\nARE YOU A ROMANCE FAN? FOLLOW THE SILVER PETTICOAT REVIEW:\\n\\nOur romance-themed entertainment site is on a mission to help you find the best period dramas, romance movies, TV shows, and books. Other topics include Jane Austen, Classic Hollywood, TV Couples, Fairy Tales, Romantic Living, Romanticism, and more. We're damsels not in distress fighting for the all-new optimistic Romantic Revolution. Join us and subscribe. For more information, see our About, Old-Fashioned Romance 101, Modern Romanticism 101, and Romantic Living 101.\"\n}", "invoking_actions": [{"name": "get_document", "arguments": {}, "id": "2ce54757-7e7f-4905-a574-98e96a7f43e3", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 1.8492050000000002, "benchmark_cost": 0.0} +{"event": "action", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 16, "action": {"name": "search", "arguments": {"query": "\"Storm and Silence\" Lilly Mr Ambrose Lord Dalgliesh encrypted file stolen accusation rescue sea storm"}, "id": "36580397-72de-4b2d-981a-af1c3ee917dd", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 2.1058900000000005, "benchmark_cost": 0.0} +{"event": "observation", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 16, "observation": {"result": "[\n {\n \"docid\": \"90027\",\n \"score\": 0.5573393106460571,\n \"snippet\": \"---\\ntitle: Storm and Silence\\ndate: 2014-04-02\\n---\\n| 'Blast, blast, blast!'\\n\\nThis article is a stub and thus, inadequate. Help Storm and Silence Wiki by expanding it. |\\n\\nStorm and Silence is the first novel in the eponymous series written by Robert Thier. It is free to read on the online community Wattpad and the Radish Fiction Mobile App. The first chapter was uploaded onto Wattpad on April 2014 and the novel was published electronically and in paperback on March 2016. It won the 2015 Award for Story of the Year and currently has a massive fanbase, with over 100 million reads on Wattpad.\\n\\nSynopsis[]\\n\\nWattpad Version[]\\n\\n\\\"It is your choice,\\\" he said, stepping so close to me that our lips were almost touching.\\n\\n\\\"Either do what I say - or get another job.\\\"\\n\\nMy heart stood still as I gazed up into his deep, dark, dangerous eyes...\\n\\nIn a world where women's only role in life is to sit at home and look pretty, Lilly is determined to fight for her freedom. There's only one problem: a powerful man blocking her way. [1]\\n\\nRadish/Published Version[]\\n\\nFreedom \\u2013 that is what Lilly Linton wants most in life. Not marriage, not a brood of squalling brats, and certainly not love, thank you very much!\\n\\nBut freedom is a rare commodity in 19th-century London, where girls are expected to spend their lives sitting at home, fully occupied with looking pretty. Lilly is at her wits' end \\u2013 until a chance encounter with a dark, dangerous and powerful stranger changes her life forever...[2]\\n\\nPlot[]\\n\\nTBA\\n\\nChapters[]\\n\\nWattpad Version[]\\n\\n- Arrested for Good Manners\\n\\n- Ape Bobby\\n\\n- Who He Really Is\\n\\n- Sweet and Solid\\n\\n- Driving Me Wild(ly)\\n\\n- Empire House\\n\\n- His Indecent Demands\\n\\n- Inventing a Sibling and Getting Poked in the Eye\\n\\n- File Fight\\n\\n- The Worst Fate Imaginable\\n\\n- The Dragon's Den\\n\\n- Practicing Impertinence\\n\\n- Ballroom Battle\\n\\n- The Sins of Mr Rikkard Ambrose\\n\\n- It gets mushy-gushy\\n\\n- Unsuitable Suitors\\n\\n- Return to the Game\\n\\n- The Peril of Flowers\\n\\n- The\"\n },\n {\n \"docid\": \"25654\",\n \"score\": 0.5317371487617493,\n \"snippet\": \"---\\ntitle: Storm and Silence (Book Series)\\ndate: 2015-08-12\\n---\\nStorm and Silence is a historical fiction and romance book series written by Robert Thier. As of 2018, there are eight books in the entire series canonically. The series has gained extensive popularity online, with over one hundred million cumulative reads on the online community Wattpad and over six million reads on the Radish Fiction App.\\n\\nThe story follows the adventures of Lilly Linton, a fiery feminist living in 19th-century London, determined to earn her freedom and independence when her life is turned upside down after a chance encounter with the ruthless financial business magnate, Mr Ambrose.\\n\\nBooks\\n\\nMain series\\n\\nCurrently, there are six books in the main series. For each book initially, the chapters are individually uploaded on a weekly basis onto Radish and then Wattpad. When completed, they are edited and published as an individual book both electronically and in paperback. However, the first book is an exception to this as chapters were uploaded to Wattpad only at the time and then the book was self-published since the author did not start using Radish until the second book.\\n\\nThe following is a chronological list of the main books formatted as: Title (year initially uploaded online | year officially published).\\n\\n- Storm and Silence (2014 | 2016)\\n\\n- In the Eye of the Storm (2016 | 2016)\\n\\n- Silence is Golden (2016 | 2017)\\n\\n- Silence Breaking (2017 | 2018)\\n\\n- Hunting for Silence (2018 | TBA)\\n\\n- Storm of Bells (2018 | TBA)\\n\\n- New Storm Rising (2020 | TBA)\\n\\n- Storm over the Caribbean\\n\\n- Silence no More\\n\\nPrequel\\n\\nThe prequel takes place shortly before the beginning of the first book in the main series. It is a stand-alone novella that was commissioned by Radish Fiction and is exclusively on the mobile app.\\n\\n- Before the Storm (2016 | \\u2014)\\n\\nSide story\\n\\nThe following work takes place in between the second and third book of the main series. It was written in thanks from the author to the fanbase for voting for the first book from the main series in the Romance category for the 2016 Goodreads Choice Awards.\\n\\n- Silent Night (2016 | \\u2014\"\n },\n {\n \"docid\": \"79197\",\n \"score\": 0.5141761302947998,\n \"snippet\": \"---\\ntitle: Mysterious Circumstances\\nauthor: David Grann\\ndate: 2004-12-13\\n---\\nRichard Lancelyn Green, the world's foremost expert on Sherlock Holmes, believed that he had finally solved the case of the missing papers. Over the past two decades, he had been looking for a trove of letters, diary entries, and manuscripts written by Sir Arthur Conan Doyle, the creator of Holmes. The archive was estimated to be worth nearly four million dollars, and was said by some to carry a deadly curse, like the one in the most famous Holmes story \\\"The Hound of the Baskervilles.\\\"\\n\\nThe papers had disappeared after Conan Doyle died, in 1930, and without them no one had been able to write a definitive biography\\u2014a task that Green was determined to complete. Many scholars feared that the archive had been discarded or destroyed; as the London Times noted earlier this year, its whereabouts had become \\\"a mystery as tantalizing as any to unfold at 221B Baker Street,\\\" the fictional den of Holmes and his fellow-sleuth, Dr. Watson.\\n\\nNot long after Green launched his investigation, he discovered that one of Conan Doyle's five children, Adrian, had, with the other heirs' agreement, stashed the papers in a locked room of a ch\\u00e2teau that he owned in Switzerland. Green then learned that Adrian had spirited some of the papers out of the ch\\u00e2teau without his siblings' knowledge, hoping to sell them to collectors. In the midst of this scheme, he died of a heart attack\\u2014giving rise to the legend of the curse. After Adrian's death, the papers apparently vanished. And whenever Green tried to probe further he found himself caught in an impenetrable web of heirs\\u2014including a self-styled Russian princess\\u2014who seemed to have deceived and double-crossed each other in their efforts to control the archive.\\n\\nFor years, Green continued to sort through evidence and interview relatives, until one day the muddled trail led to London\\u2014and the doorstep of Jean Conan Doyle, the youngest of the author's children. Tall and elegant, with silver hair, she was an imposing woman in her late sixties. (\\\"Something very strong and forceful seems to be at the back of that wee body,\\\" her father had written of Jean when she was five. \\\"Her will is tremendous.\\\") Whereas her brother Adrian had been kicked out of the British Navy for insubordination, and her elder brother Denis was a playboy who had\"\n },\n {\n \"docid\": \"82350\",\n \"score\": 0.4930114448070526,\n \"snippet\": \"---\\ntitle: \\u2630OTHER BOOKS\\ndate: 2023-01-05\\n---\\nThe Sleeping and the Dead\\n\\nIn this vivid psychological suspense novel, a diving instructor makes a gruesome discovery in Cranwell Lake - the body of a teenager who has clearly been in the water for many years.\\n\\nDetective Peter Porteous is called to Cranwell Lake where the body of a teenager has been discovered. After trawling through the missing persons files, he comes to the conclusion that the corpse is Michael Grey, an enigmatic and secretive young man who was reported missing by his foster parents in 1972.\\n\\nThe news report that a body has been found leaves prison officer Hannah Morton in shock. Michael had been her boyfriend, and she had been with him the night he disappeared. And now the discovery is bringing back dreaded and long buried memories from her past ...\\n\\nThe Sleeping and the Dead was first published in the UK in 2001. It was reissued by Pan MacMillan in their 'Ann Cleeves Classic Crime' series on 5th January 2023. Order a copy via the publisher's website, or from any bookshop or library (ISBN: 978-1-5290-7051-4).\\n\\nThe audiobook, read by John Telfer, is available in a choice of formats from the Reading House, or as an audio download from Amazon.\\n\\nBurial of Ghosts\\n\\nFor Lizzie Bartholomew, a holiday in Morocco will change life forever. But not in the way she had hoped...\\n\\nLizzie had planned her trip to Marrakech as the perfect escape from her life - and her nightmares - in Northumberland. Abandoned as a baby, and having spent her childhood moving between foster homes, Lizzie certainly has much to escape from. And for Lizzie, Morocco is the exotic paradise that she had imagined. Especially when she finds herself on a bus sitting next to a fellow tourist, who is also travelling to fulfil his dreams.\\n\\nAfter a brief affair, Lizzie returns to England. In the days that follow, she is distracted by thoughts of her mysterious lover, hoping against hope that Philip might come and find her. But suddenly she receives a letter from a firm of solicitors. Philip Samson has died. In his will, he has left Lizzie a gift of \\u00a315,000. But there are conditions attached to this unexpected legacy, conditions that will alter the\"\n },\n {\n \"docid\": \"71386\",\n \"score\": 0.4850735366344452,\n \"snippet\": \"---\\ntitle: A Taste for Death\\nauthor: Gingerninjasz\\ndate: 2025-01-01\\n---\\nSir Paul Berowne a prominent Government Minister turns to his old friend Adam Dalgleish following a series of threatening letters delivered to his London home.Sir Paul Berowne a prominent Government Minister turns to his old friend Adam Dalgleish following a series of threatening letters delivered to his London home.Sir Paul Berowne a prominent Government Minister turns to his old friend Adam Dalgleish following a series of threatening letters delivered to his London home.\\n\\nBrowse episodes\\n\\nFeatured reviews\\n\\nThis is the first P. D. James mystery I ever remember watching. Not that I'm sure it was meant for kids, but I was fortunate to see it when it was first broadcast in 1988 and it stayed in my memory ever since - and no wonder! It is without doubt one of the creepiest mysteries ever filmed, full of atmosphere and foreboding - something P. D. James was especially good at in her novels. Indeed, having read the book years later it is remarkable how they have managed to adapt this. For those familiar to James' novels, they are not always conventional in their structure, and with A Taste For Death much of what happens in the first 3 episodes has already happened when the novel begins with the discovery of Sir Paul Berrowne's body. To see how they have adapted it here with so much back story to build up is remarkable and a triumph for the screenwriter Alick Rowe, who creates a masterpiece.\\n\\nThe story here begins with Tory MP Sir Paul Berrowne (Bosco Hogan) meeting up with Adam Dalgliesh in the park, where he shows him a series of anonymous letters written to him. They allege various things, including his involvement in his 1st wife's death in a car crash and his brother Hugo's murder, whose fiance Barbara Swayne he then married. But as Dalgliesh investigates, Berrowne begins behaving rather oddly to those who know him after having a religious conversion when visiting St Matthew's Church one day. He informs his council that he intends to resign as an MP, tells his wife Barbara that he plans to sell the family home and lets her cousin (and lover) Stephen Lampart know that he intends to withdraw all funding from his abortion clinic. More disturbing is when he is seen leaving an embankment where a young girl, Diana Travers (a brave part for London\"\n }\n]", "invoking_actions": [{"name": "search", "arguments": {}, "id": "36580397-72de-4b2d-981a-af1c3ee917dd", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 2.1058900000000005, "benchmark_cost": 0.0} +{"event": "action", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 17, "action": {"name": "search", "arguments": {"query": "\"Storm and Silence\" detailed plot Ambrose file document cipher encrypted abroad Dalgliesh storm ending rescue"}, "id": "4db7a864-4361-47ec-9f85-24cd1321aa74", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 2.377825, "benchmark_cost": 0.0} +{"event": "observation", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 17, "observation": {"result": "[\n {\n \"docid\": \"90027\",\n \"score\": 0.48288753628730774,\n \"snippet\": \"---\\ntitle: Storm and Silence\\ndate: 2014-04-02\\n---\\n| 'Blast, blast, blast!'\\n\\nThis article is a stub and thus, inadequate. Help Storm and Silence Wiki by expanding it. |\\n\\nStorm and Silence is the first novel in the eponymous series written by Robert Thier. It is free to read on the online community Wattpad and the Radish Fiction Mobile App. The first chapter was uploaded onto Wattpad on April 2014 and the novel was published electronically and in paperback on March 2016. It won the 2015 Award for Story of the Year and currently has a massive fanbase, with over 100 million reads on Wattpad.\\n\\nSynopsis[]\\n\\nWattpad Version[]\\n\\n\\\"It is your choice,\\\" he said, stepping so close to me that our lips were almost touching.\\n\\n\\\"Either do what I say - or get another job.\\\"\\n\\nMy heart stood still as I gazed up into his deep, dark, dangerous eyes...\\n\\nIn a world where women's only role in life is to sit at home and look pretty, Lilly is determined to fight for her freedom. There's only one problem: a powerful man blocking her way. [1]\\n\\nRadish/Published Version[]\\n\\nFreedom \\u2013 that is what Lilly Linton wants most in life. Not marriage, not a brood of squalling brats, and certainly not love, thank you very much!\\n\\nBut freedom is a rare commodity in 19th-century London, where girls are expected to spend their lives sitting at home, fully occupied with looking pretty. Lilly is at her wits' end \\u2013 until a chance encounter with a dark, dangerous and powerful stranger changes her life forever...[2]\\n\\nPlot[]\\n\\nTBA\\n\\nChapters[]\\n\\nWattpad Version[]\\n\\n- Arrested for Good Manners\\n\\n- Ape Bobby\\n\\n- Who He Really Is\\n\\n- Sweet and Solid\\n\\n- Driving Me Wild(ly)\\n\\n- Empire House\\n\\n- His Indecent Demands\\n\\n- Inventing a Sibling and Getting Poked in the Eye\\n\\n- File Fight\\n\\n- The Worst Fate Imaginable\\n\\n- The Dragon's Den\\n\\n- Practicing Impertinence\\n\\n- Ballroom Battle\\n\\n- The Sins of Mr Rikkard Ambrose\\n\\n- It gets mushy-gushy\\n\\n- Unsuitable Suitors\\n\\n- Return to the Game\\n\\n- The Peril of Flowers\\n\\n- The\"\n },\n {\n \"docid\": \"25654\",\n \"score\": 0.4817203879356384,\n \"snippet\": \"---\\ntitle: Storm and Silence (Book Series)\\ndate: 2015-08-12\\n---\\nStorm and Silence is a historical fiction and romance book series written by Robert Thier. As of 2018, there are eight books in the entire series canonically. The series has gained extensive popularity online, with over one hundred million cumulative reads on the online community Wattpad and over six million reads on the Radish Fiction App.\\n\\nThe story follows the adventures of Lilly Linton, a fiery feminist living in 19th-century London, determined to earn her freedom and independence when her life is turned upside down after a chance encounter with the ruthless financial business magnate, Mr Ambrose.\\n\\nBooks\\n\\nMain series\\n\\nCurrently, there are six books in the main series. For each book initially, the chapters are individually uploaded on a weekly basis onto Radish and then Wattpad. When completed, they are edited and published as an individual book both electronically and in paperback. However, the first book is an exception to this as chapters were uploaded to Wattpad only at the time and then the book was self-published since the author did not start using Radish until the second book.\\n\\nThe following is a chronological list of the main books formatted as: Title (year initially uploaded online | year officially published).\\n\\n- Storm and Silence (2014 | 2016)\\n\\n- In the Eye of the Storm (2016 | 2016)\\n\\n- Silence is Golden (2016 | 2017)\\n\\n- Silence Breaking (2017 | 2018)\\n\\n- Hunting for Silence (2018 | TBA)\\n\\n- Storm of Bells (2018 | TBA)\\n\\n- New Storm Rising (2020 | TBA)\\n\\n- Storm over the Caribbean\\n\\n- Silence no More\\n\\nPrequel\\n\\nThe prequel takes place shortly before the beginning of the first book in the main series. It is a stand-alone novella that was commissioned by Radish Fiction and is exclusively on the mobile app.\\n\\n- Before the Storm (2016 | \\u2014)\\n\\nSide story\\n\\nThe following work takes place in between the second and third book of the main series. It was written in thanks from the author to the fanbase for voting for the first book from the main series in the Romance category for the 2016 Goodreads Choice Awards.\\n\\n- Silent Night (2016 | \\u2014\"\n },\n {\n \"docid\": \"79197\",\n \"score\": 0.47309380769729614,\n \"snippet\": \"---\\ntitle: Mysterious Circumstances\\nauthor: David Grann\\ndate: 2004-12-13\\n---\\nRichard Lancelyn Green, the world's foremost expert on Sherlock Holmes, believed that he had finally solved the case of the missing papers. Over the past two decades, he had been looking for a trove of letters, diary entries, and manuscripts written by Sir Arthur Conan Doyle, the creator of Holmes. The archive was estimated to be worth nearly four million dollars, and was said by some to carry a deadly curse, like the one in the most famous Holmes story \\\"The Hound of the Baskervilles.\\\"\\n\\nThe papers had disappeared after Conan Doyle died, in 1930, and without them no one had been able to write a definitive biography\\u2014a task that Green was determined to complete. Many scholars feared that the archive had been discarded or destroyed; as the London Times noted earlier this year, its whereabouts had become \\\"a mystery as tantalizing as any to unfold at 221B Baker Street,\\\" the fictional den of Holmes and his fellow-sleuth, Dr. Watson.\\n\\nNot long after Green launched his investigation, he discovered that one of Conan Doyle's five children, Adrian, had, with the other heirs' agreement, stashed the papers in a locked room of a ch\\u00e2teau that he owned in Switzerland. Green then learned that Adrian had spirited some of the papers out of the ch\\u00e2teau without his siblings' knowledge, hoping to sell them to collectors. In the midst of this scheme, he died of a heart attack\\u2014giving rise to the legend of the curse. After Adrian's death, the papers apparently vanished. And whenever Green tried to probe further he found himself caught in an impenetrable web of heirs\\u2014including a self-styled Russian princess\\u2014who seemed to have deceived and double-crossed each other in their efforts to control the archive.\\n\\nFor years, Green continued to sort through evidence and interview relatives, until one day the muddled trail led to London\\u2014and the doorstep of Jean Conan Doyle, the youngest of the author's children. Tall and elegant, with silver hair, she was an imposing woman in her late sixties. (\\\"Something very strong and forceful seems to be at the back of that wee body,\\\" her father had written of Jean when she was five. \\\"Her will is tremendous.\\\") Whereas her brother Adrian had been kicked out of the British Navy for insubordination, and her elder brother Denis was a playboy who had\"\n },\n {\n \"docid\": \"32872\",\n \"score\": 0.43913784623146057,\n \"snippet\": \"---\\ntitle: 39 Clues - A Complete List!\\nauthor: Rick Riordan\\ndate: 2000-01-01\\n---\\n39 Clues - A Complete List!\\n\\nThere are so many books in the puzzle mystery series 39 clues, and they all have different authors, but don't worry, we've got you covered with this comprehensive list of all the books in order. Let the fun begin!\\n\\nP.S.- Remember: a lot of these titles are also available in Audiobook, E-book, and E-audio! Just search the catalog!\\n\\nThe Maze Of Bones\\n\\nAuthor(s):\\n\\nDescription:\\n\\nWhat would happen if you discovered that your family was one of the most powerful in human history? What if you were told that the source of the family's power was hidden around the world, in the form of 39 clues? What if you were given a choice - take a million dollars and walk away ... or get the first clue? If you're Amy and Dan Cahill, you take the clue - and begin a very dangerous race.\\n\\nFormat:\\n\\nBook\\n\\nCall Number:\\n\\nJ FIC Rio\\n\\nOne False Note\\n\\nAuthor(s):\\n\\nDescription:\\n\\nA million dollars, or a clue? Police report a break-in at an elite hotel, and the suspects sound suspiciously like Amy and Dan. There's a car and speedboat chase and an angry mob! When there's a Clue on the line, anything can happen.\\n\\nFormat:\\n\\nBook\\n\\nCall Number:\\n\\nJ FIC Kor\\n\\nThe Sword Thief\\n\\nAuthor(s):\\n\\nDescription:\\n\\nWhen Amy and Dan Cahill's quest to find the million dollars takes them to Japan, they must decide whether or not to enter into an alliance with their uncle, Alastair Oh, whose motives for helping them are extremely questionable.\\n\\nFormat:\\n\\nBook\\n\\nCall Number:\\n\\nJ FIC Ler\\n\\nBeyond The Grave\\n\\nAuthor(s):\\n\\nDescription:\\n\\nA Clue found in Book 3 sends Amy and Dan jetting off to find out just what's behind the fierce rivalry between the Tomas and Ekaterina branches of the Cahill family. Was a Clue stolen from the Tomas branch? Where is it now? And most important, can Amy and Dan get their hands on it before their rivals do?\\n\\nFormat:\\n\\nBook\\n\\nCall Number:\\n\\nJ FIC Wat\\n\\nThe Black Circle\\n\\nAuthor(s):\\n\\nDescription:\\n\\n\\\"Where are Amy and Dan Cahill? The two kids were last seen in Egypt, hunting for one of the 39 Clues that could make them\"\n },\n {\n \"docid\": \"67868\",\n \"score\": 0.4347360134124756,\n \"snippet\": \"---\\ntitle: 25 of the best spy thrillers\\ndate: 2024-01-09\\n---\\nBooks\\n\\n25 of the best spy thrillers\\n\\nWe can't resist a dip into the mysterious world of the spy thriller. With secrets, lies, conspiracies and undercover plots abound, these books span the national and the international, blending the personal and the political and showing how they are inextricably linked.\\n\\nFrom the classic to the contemporary, here are some of the best spy thrillers around.\\n\\nThe best spy thriller books\\n\\nCasino Royale by Ian Fleming\\n\\nCasino Royale by Ian Fleming\\n\\nIn Casino Royale, the first of Ian Fleming's 007 adventures, a game of cards is James Bond's only chance to bring down Le Chiffre, French communist and paymaster of the Soviet murder organization SMERSH. But Bond soon discovers that there is far more at stake than money.\\n\\nThis is a book that's packed will thrills and suspense. It's an incredibly satisfying read that showcases everything we know and love about the Bond stories \\u2013 chilling, sadistic villains, sensuous, fiery love affairs and the smooth, sophisticated secret agent himself.\\n\\nThe Spy Coast by Tess Gerritsen\\n\\nThe Spy Coast by Tess Gerritsen\\n\\nMaggie Bird lives a quiet life as a retiree in a seaside town. She never talks about her past life as a former spy. But when an unidentified body is left on Maggie's doorway, she knows it's a calling card from old times. Enter the 'Martini Club' \\u2013 Maggie's group of former spy friends. Together, they must solve the mystery and stay one step ahead of law enforcement.\\n\\nThis is the first instalment in a brand-new espionage series from Tess Gerritsen, author of the beloved Rizzoli and Isles detective series.\\n\\nThe Day of the Jackal by Frederick Forsyth\\n\\nThe Day of the Jackal by Frederick Forsyth\\n\\nThis book by former RAF pilot and investigative journalist Frederick Forsyth is one of the most celebrated thrillers ever written. It's intelligent, chilling and 100% unputdownable.\\n\\nIt is 1963 and an anonymous Englishman has been hired by the O.A.S. to murder General de Galle. A failed attempt means the target will be nearly impossible to get to. But this latest plot involves a lethal weapon: an assassin of legendary talent. Known only as The Jackal, this remorseless and deadly killer must be stopped \\u2013 but\"\n }\n]", "invoking_actions": [{"name": "search", "arguments": {}, "id": "4db7a864-4361-47ec-9f85-24cd1321aa74", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 2.377825, "benchmark_cost": 0.0} +{"event": "action", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 18, "action": {"name": "search", "arguments": {"query": "Robert Thier Storm Silence Ambrose Lilly Linton climax shipwreck island rescue save"}, "id": "816bfa41-a34b-4252-bbae-91cb75e8736b", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 2.66776, "benchmark_cost": 0.0} +{"event": "observation", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 18, "observation": {"result": "[\n {\n \"docid\": \"90027\",\n \"score\": 0.6299043893814087,\n \"snippet\": \"---\\ntitle: Storm and Silence\\ndate: 2014-04-02\\n---\\n| 'Blast, blast, blast!'\\n\\nThis article is a stub and thus, inadequate. Help Storm and Silence Wiki by expanding it. |\\n\\nStorm and Silence is the first novel in the eponymous series written by Robert Thier. It is free to read on the online community Wattpad and the Radish Fiction Mobile App. The first chapter was uploaded onto Wattpad on April 2014 and the novel was published electronically and in paperback on March 2016. It won the 2015 Award for Story of the Year and currently has a massive fanbase, with over 100 million reads on Wattpad.\\n\\nSynopsis[]\\n\\nWattpad Version[]\\n\\n\\\"It is your choice,\\\" he said, stepping so close to me that our lips were almost touching.\\n\\n\\\"Either do what I say - or get another job.\\\"\\n\\nMy heart stood still as I gazed up into his deep, dark, dangerous eyes...\\n\\nIn a world where women's only role in life is to sit at home and look pretty, Lilly is determined to fight for her freedom. There's only one problem: a powerful man blocking her way. [1]\\n\\nRadish/Published Version[]\\n\\nFreedom \\u2013 that is what Lilly Linton wants most in life. Not marriage, not a brood of squalling brats, and certainly not love, thank you very much!\\n\\nBut freedom is a rare commodity in 19th-century London, where girls are expected to spend their lives sitting at home, fully occupied with looking pretty. Lilly is at her wits' end \\u2013 until a chance encounter with a dark, dangerous and powerful stranger changes her life forever...[2]\\n\\nPlot[]\\n\\nTBA\\n\\nChapters[]\\n\\nWattpad Version[]\\n\\n- Arrested for Good Manners\\n\\n- Ape Bobby\\n\\n- Who He Really Is\\n\\n- Sweet and Solid\\n\\n- Driving Me Wild(ly)\\n\\n- Empire House\\n\\n- His Indecent Demands\\n\\n- Inventing a Sibling and Getting Poked in the Eye\\n\\n- File Fight\\n\\n- The Worst Fate Imaginable\\n\\n- The Dragon's Den\\n\\n- Practicing Impertinence\\n\\n- Ballroom Battle\\n\\n- The Sins of Mr Rikkard Ambrose\\n\\n- It gets mushy-gushy\\n\\n- Unsuitable Suitors\\n\\n- Return to the Game\\n\\n- The Peril of Flowers\\n\\n- The\"\n },\n {\n \"docid\": \"25654\",\n \"score\": 0.5731695890426636,\n \"snippet\": \"---\\ntitle: Storm and Silence (Book Series)\\ndate: 2015-08-12\\n---\\nStorm and Silence is a historical fiction and romance book series written by Robert Thier. As of 2018, there are eight books in the entire series canonically. The series has gained extensive popularity online, with over one hundred million cumulative reads on the online community Wattpad and over six million reads on the Radish Fiction App.\\n\\nThe story follows the adventures of Lilly Linton, a fiery feminist living in 19th-century London, determined to earn her freedom and independence when her life is turned upside down after a chance encounter with the ruthless financial business magnate, Mr Ambrose.\\n\\nBooks\\n\\nMain series\\n\\nCurrently, there are six books in the main series. For each book initially, the chapters are individually uploaded on a weekly basis onto Radish and then Wattpad. When completed, they are edited and published as an individual book both electronically and in paperback. However, the first book is an exception to this as chapters were uploaded to Wattpad only at the time and then the book was self-published since the author did not start using Radish until the second book.\\n\\nThe following is a chronological list of the main books formatted as: Title (year initially uploaded online | year officially published).\\n\\n- Storm and Silence (2014 | 2016)\\n\\n- In the Eye of the Storm (2016 | 2016)\\n\\n- Silence is Golden (2016 | 2017)\\n\\n- Silence Breaking (2017 | 2018)\\n\\n- Hunting for Silence (2018 | TBA)\\n\\n- Storm of Bells (2018 | TBA)\\n\\n- New Storm Rising (2020 | TBA)\\n\\n- Storm over the Caribbean\\n\\n- Silence no More\\n\\nPrequel\\n\\nThe prequel takes place shortly before the beginning of the first book in the main series. It is a stand-alone novella that was commissioned by Radish Fiction and is exclusively on the mobile app.\\n\\n- Before the Storm (2016 | \\u2014)\\n\\nSide story\\n\\nThe following work takes place in between the second and third book of the main series. It was written in thanks from the author to the fanbase for voting for the first book from the main series in the Romance category for the 2016 Goodreads Choice Awards.\\n\\n- Silent Night (2016 | \\u2014\"\n },\n {\n \"docid\": \"71659\",\n \"score\": 0.5616115927696228,\n \"snippet\": \"---\\ntitle: REVIEW: Storm and Silence by Robert Thier\\nauthor: Nikka\\ndate: 2016-04-03\\n---\\nP A P E R & T R E E S\\nA Book Blog\\n\\nREVIEW: Storm and Silence by Robert Thier\\nA copy of this book was provided by the authorin exchange for an honest review. This does not affect my opinion of the book or the content of my review.\\n\\nSeries: Storm and Silence #1Publication Date: March 19 2016 by Robert Thier\\nGenres: Romance, Historical Fiction\\nNumber of Pages: 596\\nMy Rating: \\u2605\\u2605\\u2605\\u2729\\u2729\\nAmazon // Book Depository // iBooksGoodreads\\n\\nFreedom \\u2013 that is what Lilly Linton wants most in life. Not marriage, not a brood of squalling brats, and certainly not love, thank you very much!\\nBut freedom is a rare commodity in 19th-century London, where girls are expected to spend their lives sitting at home, fully occupied with looking pretty. Lilly is at her wits' end \\u2013 until a chance encounter with a dark, dangerous and powerful stranger changes her life forever\\u2026\\nEnter the world of Mr Rikkard Ambrose, where the only rule is: Knowledge is power is time is money!\\n\\nThe story is set in 19th-century London where women, although dressed beautifully and often treated as fragile flowers, do not have the liberty to pursue their own interests. From education to marriage to rights, women were oppressed and seen as naive and incapable of living on their own.\\nAs Tiffany Stelle retold, \\\"She was expected to keep the house clean, cook the meals, raise the children, decorate the house, and keep her children and husband on the moral high ground. When a husband came home from work he was expecting a smile on his wife, who is dressed perfectly, a house that is spotless and bright, and a forgiving happy environment that would lure him into wanting to return home each day.\\\"\\nBut for Lilly Linton, this is not the way things are supposed to be. She is a suffragette, a feminist, and she needs freedom as much as the next guy needs his daily dose of vanity. So she did what a feminist woman did during the Victorian era\\u2014she pretended to be a man. But unlike any other woman, she was offered a job and she took it, for this could be the key to the freedom she's longing for.\\nThis book\"\n },\n {\n \"docid\": \"29859\",\n \"score\": 0.5189592242240906,\n \"snippet\": \"---\\ntitle: The Tides of Time\\nauthor: Sarah M Eden this is a placeholder\\ndate: 2025-01-01\\n---\\nWhat do you think?\\n\\nRate this book\\n\\nIn 1793, a storm propels Lili forward through time, kindling a love that transcends the ages.\\n\\nFleeing the clutches of Robespierre's revolutionary Tribunal in France, Lili Minet makes a desperate escape on a ship headed to England, but her dangerous flight takes an unexpected turn when a violent storm catapults her off the ship and eighty years into the future, leaving her stranded in the unfamiliar world of 1873 England.\\n\\nWhen lighthouse keeper Armitage Pierce rescues a woman from the tumultuous sea, he does not anticipate her silent, cold response to his gallant efforts. Though he is wary of this woman and her odd behaviors, he finds she is just as wary of him. And he can sense that she is not telling him something of great import to her. Only Armitage's grandfather, a man seasoned by the mysteries of the sea, can seem to penetrate Lili's defenses to offer her support. But as Lili heals from the physical and emotional wounds of her ordeal and Armitage continues to offer light and safety to her, a tender friendship blossoms between the two.\\n\\nYet the shadow of danger looms as the threat that chased Lili from France all those years ago reemerges in her new present. Together Lili and Armitage must navigate the challenges of a romance that grows to defy the boundaries of time and the perils that reach across the decades to ensnare Lili. As the storm clouds gather, Lili and Armitage face the ultimate test\\u2014discovering whether their bond is strong enough to rewrite the pages of history itself to save them and their love.\\n\\nFleeing the clutches of Robespierre's revolutionary Tribunal in France, Lili Minet makes a desperate escape on a ship headed to England, but her dangerous flight takes an unexpected turn when a violent storm catapults her off the ship and eighty years into the future, leaving her stranded in the unfamiliar world of 1873 England.\\n\\nWhen lighthouse keeper Armitage Pierce rescues a woman from the tumultuous sea, he does not anticipate her silent, cold response to his gallant efforts. Though he is wary of this woman and her odd behaviors, he finds she is just as wary of him. And\"\n },\n {\n \"docid\": \"72764\",\n \"score\": 0.5175468325614929,\n \"snippet\": \"---\\ntitle: Overboard!: A True Blue-water Odyssey of Disaster and Survival Hardcover \\u2013 March 16, 2010\\nauthor: Michael J Tougias\\ndate: 2010-03-16\\n---\\n-24% $18.15$18.15\\n\\nDelivery Sunday, June 15\\n\\nShips from: Amazon Sold by: Bookdream\\n\\n$13.79$13.79\\n\\nDelivery Thursday, June 19\\n\\nShips from: Amazon Sold by: Zoom Books Company\\n\\nReturn this item for free\\n\\nFree returns are available for the shipping address you chose. You can return the item for any reason in new and unused condition: no return shipping charges.\\n\\nLearn more about free returns.- Go to your orders and start the return\\n\\n- Select your preferred free shipping option\\n\\n- Drop off and leave!\\n\\nSorry, there was a problem.\\n\\nThere was an error retrieving your Wish Lists. Please try again.Sorry, there was a problem.\\n\\nList unavailable.Download the free Kindle app and start reading Kindle books instantly on your smartphone, tablet, or computer - no Kindle device required.\\n\\nRead instantly on your browser with Kindle for Web.\\n\\nUsing your mobile phone camera - scan the code below and download the Kindle app.\\n\\nAudible sample\\n\\nFollow the author\\n\\nOK\\n\\nOverboard!: A True Blue-water Odyssey of Disaster and Survival Hardcover \\u2013 March 16, 2010\\n\\nPurchase options and add-ons\\n\\nIn May 2005, Tom Tighe, captain of a forty-five-foot-long sailboat named the Almeisan, and his first mate, Loch Reidy, welcomed three new crewmembers for a five-day voyage from Connecticut across the blue waters of the Gulf Stream to sun-drenched Bermuda. The new crew included forty-six-year-old Kathy Gilchrist, seventy-year-old Ron Burd, and thirty-four-year-old Chris Ferrer. Although Tighe had made the trip forty-eight times, with Reidy accompanying him on twenty of those voyages, the rest of the crew had joined to learn more about offshore sailing.\\n\\nFour days into the voyage, an enormous storm struck, sweeping two of the crew into the towering sea. The remaining crewmembers managed to stay aboard the vessel as it was slowly torn apart by the rampaging ocean. Overboard! follows the simultaneous desperate struggles of both those still on the boat and those fighting for their lives in the sea.\\n\\nThe Coast Guard, alerted to the Almeisan's\"\n }\n]", "invoking_actions": [{"name": "search", "arguments": {}, "id": "816bfa41-a34b-4252-bbae-91cb75e8736b", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 2.66776, "benchmark_cost": 0.0} +{"event": "action", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 19, "action": {"name": "search", "arguments": {"query": "\"Storm and Silence\" \"file\" \"thief\" accused \"stolen\" Lilly Ambrose Dalgliesh island"}, "id": "f6ae43d2-ea12-4115-81ab-ce3b67f6bc2b", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 2.97542, "benchmark_cost": 0.0} +{"event": "observation", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 19, "observation": {"result": "[\n {\n \"docid\": \"90027\",\n \"score\": 0.5347017049789429,\n \"snippet\": \"---\\ntitle: Storm and Silence\\ndate: 2014-04-02\\n---\\n| 'Blast, blast, blast!'\\n\\nThis article is a stub and thus, inadequate. Help Storm and Silence Wiki by expanding it. |\\n\\nStorm and Silence is the first novel in the eponymous series written by Robert Thier. It is free to read on the online community Wattpad and the Radish Fiction Mobile App. The first chapter was uploaded onto Wattpad on April 2014 and the novel was published electronically and in paperback on March 2016. It won the 2015 Award for Story of the Year and currently has a massive fanbase, with over 100 million reads on Wattpad.\\n\\nSynopsis[]\\n\\nWattpad Version[]\\n\\n\\\"It is your choice,\\\" he said, stepping so close to me that our lips were almost touching.\\n\\n\\\"Either do what I say - or get another job.\\\"\\n\\nMy heart stood still as I gazed up into his deep, dark, dangerous eyes...\\n\\nIn a world where women's only role in life is to sit at home and look pretty, Lilly is determined to fight for her freedom. There's only one problem: a powerful man blocking her way. [1]\\n\\nRadish/Published Version[]\\n\\nFreedom \\u2013 that is what Lilly Linton wants most in life. Not marriage, not a brood of squalling brats, and certainly not love, thank you very much!\\n\\nBut freedom is a rare commodity in 19th-century London, where girls are expected to spend their lives sitting at home, fully occupied with looking pretty. Lilly is at her wits' end \\u2013 until a chance encounter with a dark, dangerous and powerful stranger changes her life forever...[2]\\n\\nPlot[]\\n\\nTBA\\n\\nChapters[]\\n\\nWattpad Version[]\\n\\n- Arrested for Good Manners\\n\\n- Ape Bobby\\n\\n- Who He Really Is\\n\\n- Sweet and Solid\\n\\n- Driving Me Wild(ly)\\n\\n- Empire House\\n\\n- His Indecent Demands\\n\\n- Inventing a Sibling and Getting Poked in the Eye\\n\\n- File Fight\\n\\n- The Worst Fate Imaginable\\n\\n- The Dragon's Den\\n\\n- Practicing Impertinence\\n\\n- Ballroom Battle\\n\\n- The Sins of Mr Rikkard Ambrose\\n\\n- It gets mushy-gushy\\n\\n- Unsuitable Suitors\\n\\n- Return to the Game\\n\\n- The Peril of Flowers\\n\\n- The\"\n },\n {\n \"docid\": \"25654\",\n \"score\": 0.5029191970825195,\n \"snippet\": \"---\\ntitle: Storm and Silence (Book Series)\\ndate: 2015-08-12\\n---\\nStorm and Silence is a historical fiction and romance book series written by Robert Thier. As of 2018, there are eight books in the entire series canonically. The series has gained extensive popularity online, with over one hundred million cumulative reads on the online community Wattpad and over six million reads on the Radish Fiction App.\\n\\nThe story follows the adventures of Lilly Linton, a fiery feminist living in 19th-century London, determined to earn her freedom and independence when her life is turned upside down after a chance encounter with the ruthless financial business magnate, Mr Ambrose.\\n\\nBooks\\n\\nMain series\\n\\nCurrently, there are six books in the main series. For each book initially, the chapters are individually uploaded on a weekly basis onto Radish and then Wattpad. When completed, they are edited and published as an individual book both electronically and in paperback. However, the first book is an exception to this as chapters were uploaded to Wattpad only at the time and then the book was self-published since the author did not start using Radish until the second book.\\n\\nThe following is a chronological list of the main books formatted as: Title (year initially uploaded online | year officially published).\\n\\n- Storm and Silence (2014 | 2016)\\n\\n- In the Eye of the Storm (2016 | 2016)\\n\\n- Silence is Golden (2016 | 2017)\\n\\n- Silence Breaking (2017 | 2018)\\n\\n- Hunting for Silence (2018 | TBA)\\n\\n- Storm of Bells (2018 | TBA)\\n\\n- New Storm Rising (2020 | TBA)\\n\\n- Storm over the Caribbean\\n\\n- Silence no More\\n\\nPrequel\\n\\nThe prequel takes place shortly before the beginning of the first book in the main series. It is a stand-alone novella that was commissioned by Radish Fiction and is exclusively on the mobile app.\\n\\n- Before the Storm (2016 | \\u2014)\\n\\nSide story\\n\\nThe following work takes place in between the second and third book of the main series. It was written in thanks from the author to the fanbase for voting for the first book from the main series in the Romance category for the 2016 Goodreads Choice Awards.\\n\\n- Silent Night (2016 | \\u2014\"\n },\n {\n \"docid\": \"79197\",\n \"score\": 0.48140305280685425,\n \"snippet\": \"---\\ntitle: Mysterious Circumstances\\nauthor: David Grann\\ndate: 2004-12-13\\n---\\nRichard Lancelyn Green, the world's foremost expert on Sherlock Holmes, believed that he had finally solved the case of the missing papers. Over the past two decades, he had been looking for a trove of letters, diary entries, and manuscripts written by Sir Arthur Conan Doyle, the creator of Holmes. The archive was estimated to be worth nearly four million dollars, and was said by some to carry a deadly curse, like the one in the most famous Holmes story \\\"The Hound of the Baskervilles.\\\"\\n\\nThe papers had disappeared after Conan Doyle died, in 1930, and without them no one had been able to write a definitive biography\\u2014a task that Green was determined to complete. Many scholars feared that the archive had been discarded or destroyed; as the London Times noted earlier this year, its whereabouts had become \\\"a mystery as tantalizing as any to unfold at 221B Baker Street,\\\" the fictional den of Holmes and his fellow-sleuth, Dr. Watson.\\n\\nNot long after Green launched his investigation, he discovered that one of Conan Doyle's five children, Adrian, had, with the other heirs' agreement, stashed the papers in a locked room of a ch\\u00e2teau that he owned in Switzerland. Green then learned that Adrian had spirited some of the papers out of the ch\\u00e2teau without his siblings' knowledge, hoping to sell them to collectors. In the midst of this scheme, he died of a heart attack\\u2014giving rise to the legend of the curse. After Adrian's death, the papers apparently vanished. And whenever Green tried to probe further he found himself caught in an impenetrable web of heirs\\u2014including a self-styled Russian princess\\u2014who seemed to have deceived and double-crossed each other in their efforts to control the archive.\\n\\nFor years, Green continued to sort through evidence and interview relatives, until one day the muddled trail led to London\\u2014and the doorstep of Jean Conan Doyle, the youngest of the author's children. Tall and elegant, with silver hair, she was an imposing woman in her late sixties. (\\\"Something very strong and forceful seems to be at the back of that wee body,\\\" her father had written of Jean when she was five. \\\"Her will is tremendous.\\\") Whereas her brother Adrian had been kicked out of the British Navy for insubordination, and her elder brother Denis was a playboy who had\"\n },\n {\n \"docid\": \"82350\",\n \"score\": 0.4643110930919647,\n \"snippet\": \"---\\ntitle: \\u2630OTHER BOOKS\\ndate: 2023-01-05\\n---\\nThe Sleeping and the Dead\\n\\nIn this vivid psychological suspense novel, a diving instructor makes a gruesome discovery in Cranwell Lake - the body of a teenager who has clearly been in the water for many years.\\n\\nDetective Peter Porteous is called to Cranwell Lake where the body of a teenager has been discovered. After trawling through the missing persons files, he comes to the conclusion that the corpse is Michael Grey, an enigmatic and secretive young man who was reported missing by his foster parents in 1972.\\n\\nThe news report that a body has been found leaves prison officer Hannah Morton in shock. Michael had been her boyfriend, and she had been with him the night he disappeared. And now the discovery is bringing back dreaded and long buried memories from her past ...\\n\\nThe Sleeping and the Dead was first published in the UK in 2001. It was reissued by Pan MacMillan in their 'Ann Cleeves Classic Crime' series on 5th January 2023. Order a copy via the publisher's website, or from any bookshop or library (ISBN: 978-1-5290-7051-4).\\n\\nThe audiobook, read by John Telfer, is available in a choice of formats from the Reading House, or as an audio download from Amazon.\\n\\nBurial of Ghosts\\n\\nFor Lizzie Bartholomew, a holiday in Morocco will change life forever. But not in the way she had hoped...\\n\\nLizzie had planned her trip to Marrakech as the perfect escape from her life - and her nightmares - in Northumberland. Abandoned as a baby, and having spent her childhood moving between foster homes, Lizzie certainly has much to escape from. And for Lizzie, Morocco is the exotic paradise that she had imagined. Especially when she finds herself on a bus sitting next to a fellow tourist, who is also travelling to fulfil his dreams.\\n\\nAfter a brief affair, Lizzie returns to England. In the days that follow, she is distracted by thoughts of her mysterious lover, hoping against hope that Philip might come and find her. But suddenly she receives a letter from a firm of solicitors. Philip Samson has died. In his will, he has left Lizzie a gift of \\u00a315,000. But there are conditions attached to this unexpected legacy, conditions that will alter the\"\n },\n {\n \"docid\": \"66065\",\n \"score\": 0.46270695328712463,\n \"snippet\": \"---\\ntitle: James Patterson \\u2013 Books \\u2013 Standalone Thrillers\\ndate: 1996-12-01\\n---\\nJames Patterson \\u2013 Books \\u2013 Standalone Thrillers\\n\\nSTANDALONE THRILLERS\\n\\nTitles List\\n\\n-\\n\\nA $100 million painting. A previously unknown Picasso is discovered in the attic of a French villa. Everyone wants to possess it. Filthy-rich Manhattan art people. Organized crime bosses. Power-hungry government officials. A notorious forger. A glamorous twenty-two-year-old art thief. Only one person\\u2026 Read More\\n\\nLittle, Brown and Company -\\n\\nFrom #1 New York Times bestselling author James Patterson, six kidnappers pursue a whopping ten-figure payout in this thrilling novel featuring \\\"breakneck pacing and loop-the-loop plotting\\\" (Publishers Weekly). Five members of a billionaire's family. In different locations. All kidnapped at\\u2026 Read More\\n\\nLittle, Brown and Company -\\n\\n\\\"Bill Clinton reveals key White House details in murderous new political thriller\\u2026 It's a twisty thriller with plenty of inside jobs, political sabotage and many, many deaths.\\\" \\u2013USA TodayAmerica has a powerful new president\\u2026 And her husband's on trial for murder. Clinton\\u2026 Read More\\n\\nLittle, Brown and Company -\\n\\n\\\"Consider blocking out a few hours of uninterrupted reading time\\\" for The Writer, #1 New York Times bestselling author James Patterson's \\\"Excellent\\u2026perfectly executed\\u2026genuinely suspenseful\\\" (Booklist) thriller about a true-crime author swept up in a murder plot.\\\"Entertaining\\u2026one gonzo plot twist follows the next\\u2026loads of fun.\\\" (Publishers Weekly)\\u2026 Read More\\n\\nLittle, Brown and Company -\\n\\nThe police are called to stop two teens ransacking a small-town grocery store. They have no identification. And they won't speak a word. And the small town of Kokanee Creek is suddenly plunged into a sinister mystery that only #1 bestseller James Patterson\\u2026 Read More\\n\\nLittle, Brown and Company -\\n\\nIn this thrilling novel from the authors of the bestselling Death of the Black Widow, the police of a small New England town are confronted with a baffling series of crimes. Hollows Bend, New Hampshire, is a picture-perfect New England\\u2026 Read More\\n\\nLittle, Brown and Company -\\n\\nIt's the perfect getaway. But the past will always find you . . . When ex-cop Bill Robinson takes over The Inn by the Sea, all he wants is a quiet escape from the city. But when a crime boss\"\n }\n]", "invoking_actions": [{"name": "search", "arguments": {}, "id": "f6ae43d2-ea12-4115-81ab-ce3b67f6bc2b", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 2.97542, "benchmark_cost": 0.0} +{"event": "action", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 20, "action": {"name": "search", "arguments": {"query": "\"Storm and Silence\" summary plot Lilly accused thief Ambrose save rescue sea isle Marbeau climax"}, "id": "2c22b0f4-7e6d-443b-a46f-d7e3ff465bca", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 3.30073, "benchmark_cost": 0.0} +{"event": "observation", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 20, "observation": {"result": "[\n {\n \"docid\": \"90027\",\n \"score\": 0.6362643241882324,\n \"snippet\": \"---\\ntitle: Storm and Silence\\ndate: 2014-04-02\\n---\\n| 'Blast, blast, blast!'\\n\\nThis article is a stub and thus, inadequate. Help Storm and Silence Wiki by expanding it. |\\n\\nStorm and Silence is the first novel in the eponymous series written by Robert Thier. It is free to read on the online community Wattpad and the Radish Fiction Mobile App. The first chapter was uploaded onto Wattpad on April 2014 and the novel was published electronically and in paperback on March 2016. It won the 2015 Award for Story of the Year and currently has a massive fanbase, with over 100 million reads on Wattpad.\\n\\nSynopsis[]\\n\\nWattpad Version[]\\n\\n\\\"It is your choice,\\\" he said, stepping so close to me that our lips were almost touching.\\n\\n\\\"Either do what I say - or get another job.\\\"\\n\\nMy heart stood still as I gazed up into his deep, dark, dangerous eyes...\\n\\nIn a world where women's only role in life is to sit at home and look pretty, Lilly is determined to fight for her freedom. There's only one problem: a powerful man blocking her way. [1]\\n\\nRadish/Published Version[]\\n\\nFreedom \\u2013 that is what Lilly Linton wants most in life. Not marriage, not a brood of squalling brats, and certainly not love, thank you very much!\\n\\nBut freedom is a rare commodity in 19th-century London, where girls are expected to spend their lives sitting at home, fully occupied with looking pretty. Lilly is at her wits' end \\u2013 until a chance encounter with a dark, dangerous and powerful stranger changes her life forever...[2]\\n\\nPlot[]\\n\\nTBA\\n\\nChapters[]\\n\\nWattpad Version[]\\n\\n- Arrested for Good Manners\\n\\n- Ape Bobby\\n\\n- Who He Really Is\\n\\n- Sweet and Solid\\n\\n- Driving Me Wild(ly)\\n\\n- Empire House\\n\\n- His Indecent Demands\\n\\n- Inventing a Sibling and Getting Poked in the Eye\\n\\n- File Fight\\n\\n- The Worst Fate Imaginable\\n\\n- The Dragon's Den\\n\\n- Practicing Impertinence\\n\\n- Ballroom Battle\\n\\n- The Sins of Mr Rikkard Ambrose\\n\\n- It gets mushy-gushy\\n\\n- Unsuitable Suitors\\n\\n- Return to the Game\\n\\n- The Peril of Flowers\\n\\n- The\"\n },\n {\n \"docid\": \"25654\",\n \"score\": 0.5757802724838257,\n \"snippet\": \"---\\ntitle: Storm and Silence (Book Series)\\ndate: 2015-08-12\\n---\\nStorm and Silence is a historical fiction and romance book series written by Robert Thier. As of 2018, there are eight books in the entire series canonically. The series has gained extensive popularity online, with over one hundred million cumulative reads on the online community Wattpad and over six million reads on the Radish Fiction App.\\n\\nThe story follows the adventures of Lilly Linton, a fiery feminist living in 19th-century London, determined to earn her freedom and independence when her life is turned upside down after a chance encounter with the ruthless financial business magnate, Mr Ambrose.\\n\\nBooks\\n\\nMain series\\n\\nCurrently, there are six books in the main series. For each book initially, the chapters are individually uploaded on a weekly basis onto Radish and then Wattpad. When completed, they are edited and published as an individual book both electronically and in paperback. However, the first book is an exception to this as chapters were uploaded to Wattpad only at the time and then the book was self-published since the author did not start using Radish until the second book.\\n\\nThe following is a chronological list of the main books formatted as: Title (year initially uploaded online | year officially published).\\n\\n- Storm and Silence (2014 | 2016)\\n\\n- In the Eye of the Storm (2016 | 2016)\\n\\n- Silence is Golden (2016 | 2017)\\n\\n- Silence Breaking (2017 | 2018)\\n\\n- Hunting for Silence (2018 | TBA)\\n\\n- Storm of Bells (2018 | TBA)\\n\\n- New Storm Rising (2020 | TBA)\\n\\n- Storm over the Caribbean\\n\\n- Silence no More\\n\\nPrequel\\n\\nThe prequel takes place shortly before the beginning of the first book in the main series. It is a stand-alone novella that was commissioned by Radish Fiction and is exclusively on the mobile app.\\n\\n- Before the Storm (2016 | \\u2014)\\n\\nSide story\\n\\nThe following work takes place in between the second and third book of the main series. It was written in thanks from the author to the fanbase for voting for the first book from the main series in the Romance category for the 2016 Goodreads Choice Awards.\\n\\n- Silent Night (2016 | \\u2014\"\n },\n {\n \"docid\": \"71659\",\n \"score\": 0.5378220081329346,\n \"snippet\": \"---\\ntitle: REVIEW: Storm and Silence by Robert Thier\\nauthor: Nikka\\ndate: 2016-04-03\\n---\\nP A P E R & T R E E S\\nA Book Blog\\n\\nREVIEW: Storm and Silence by Robert Thier\\nA copy of this book was provided by the authorin exchange for an honest review. This does not affect my opinion of the book or the content of my review.\\n\\nSeries: Storm and Silence #1Publication Date: March 19 2016 by Robert Thier\\nGenres: Romance, Historical Fiction\\nNumber of Pages: 596\\nMy Rating: \\u2605\\u2605\\u2605\\u2729\\u2729\\nAmazon // Book Depository // iBooksGoodreads\\n\\nFreedom \\u2013 that is what Lilly Linton wants most in life. Not marriage, not a brood of squalling brats, and certainly not love, thank you very much!\\nBut freedom is a rare commodity in 19th-century London, where girls are expected to spend their lives sitting at home, fully occupied with looking pretty. Lilly is at her wits' end \\u2013 until a chance encounter with a dark, dangerous and powerful stranger changes her life forever\\u2026\\nEnter the world of Mr Rikkard Ambrose, where the only rule is: Knowledge is power is time is money!\\n\\nThe story is set in 19th-century London where women, although dressed beautifully and often treated as fragile flowers, do not have the liberty to pursue their own interests. From education to marriage to rights, women were oppressed and seen as naive and incapable of living on their own.\\nAs Tiffany Stelle retold, \\\"She was expected to keep the house clean, cook the meals, raise the children, decorate the house, and keep her children and husband on the moral high ground. When a husband came home from work he was expecting a smile on his wife, who is dressed perfectly, a house that is spotless and bright, and a forgiving happy environment that would lure him into wanting to return home each day.\\\"\\nBut for Lilly Linton, this is not the way things are supposed to be. She is a suffragette, a feminist, and she needs freedom as much as the next guy needs his daily dose of vanity. So she did what a feminist woman did during the Victorian era\\u2014she pretended to be a man. But unlike any other woman, she was offered a job and she took it, for this could be the key to the freedom she's longing for.\\nThis book\"\n },\n {\n \"docid\": \"29859\",\n \"score\": 0.5128501653671265,\n \"snippet\": \"---\\ntitle: The Tides of Time\\nauthor: Sarah M Eden this is a placeholder\\ndate: 2025-01-01\\n---\\nWhat do you think?\\n\\nRate this book\\n\\nIn 1793, a storm propels Lili forward through time, kindling a love that transcends the ages.\\n\\nFleeing the clutches of Robespierre's revolutionary Tribunal in France, Lili Minet makes a desperate escape on a ship headed to England, but her dangerous flight takes an unexpected turn when a violent storm catapults her off the ship and eighty years into the future, leaving her stranded in the unfamiliar world of 1873 England.\\n\\nWhen lighthouse keeper Armitage Pierce rescues a woman from the tumultuous sea, he does not anticipate her silent, cold response to his gallant efforts. Though he is wary of this woman and her odd behaviors, he finds she is just as wary of him. And he can sense that she is not telling him something of great import to her. Only Armitage's grandfather, a man seasoned by the mysteries of the sea, can seem to penetrate Lili's defenses to offer her support. But as Lili heals from the physical and emotional wounds of her ordeal and Armitage continues to offer light and safety to her, a tender friendship blossoms between the two.\\n\\nYet the shadow of danger looms as the threat that chased Lili from France all those years ago reemerges in her new present. Together Lili and Armitage must navigate the challenges of a romance that grows to defy the boundaries of time and the perils that reach across the decades to ensnare Lili. As the storm clouds gather, Lili and Armitage face the ultimate test\\u2014discovering whether their bond is strong enough to rewrite the pages of history itself to save them and their love.\\n\\nFleeing the clutches of Robespierre's revolutionary Tribunal in France, Lili Minet makes a desperate escape on a ship headed to England, but her dangerous flight takes an unexpected turn when a violent storm catapults her off the ship and eighty years into the future, leaving her stranded in the unfamiliar world of 1873 England.\\n\\nWhen lighthouse keeper Armitage Pierce rescues a woman from the tumultuous sea, he does not anticipate her silent, cold response to his gallant efforts. Though he is wary of this woman and her odd behaviors, he finds she is just as wary of him. And\"\n },\n {\n \"docid\": \"7830\",\n \"score\": 0.470270037651062,\n \"snippet\": \"---\\ntitle: The Light Between Oceans\\nauthor: Author this is a placeholder\\ndate: 2012-03-20\\n---\\nWhat do you think?\\n\\nRate this book\\n\\nGoodreads Choice Award\\n\\nWinner for Readers' Favorite Historical Fiction (2012)Librarian's note: An alternate cover edition can be found here\\n\\nAustralia, 1926. After four harrowing years fighting on the Western Front, Tom Sherbourne returns home to take a job as the lighthouse keeper on Janus Rock, nearly half a day's journey from the coast. To this isolated island, where the supply boat comes once a season and shore leaves are granted every other year at best, Tom brings a young, bold, and loving wife, Isabel. Years later, after two miscarriages and one stillbirth, the grieving Isabel hears a baby's cries on the wind. A boat has washed up onshore carrying a dead man and a living baby.\\n\\nTom, whose records as a lighthouse keeper are meticulous and whose moral principles have withstood a horrific war, wants to report the man and infant immediately. But Isabel has taken the tiny baby to her breast. Against Tom's judgment, they claim her as their own and name her Lucy. When she is two, Tom and Isabel return to the mainland and are reminded that there are other people in the world. Their choice has devastated one of them.\\n\\nM. L. Stedman's mesmerizing, beautifully written debut novel seduces us into accommodating Isabel's decision to keep this \\\"gift from God.\\\" And we are swept into a story about extraordinarily compelling characters seeking to find their North Star in a world where there is no right answer, where justice for one person is another's tragic loss.\\n\\nAustralia, 1926. After four harrowing years fighting on the Western Front, Tom Sherbourne returns home to take a job as the lighthouse keeper on Janus Rock, nearly half a day's journey from the coast. To this isolated island, where the supply boat comes once a season and shore leaves are granted every other year at best, Tom brings a young, bold, and loving wife, Isabel. Years later, after two miscarriages and one stillbirth, the grieving Isabel hears a baby's cries on the wind. A boat has washed up onshore carrying a dead man and a living baby.\\n\\nTom, whose records as a lighthouse keeper are meticulous and whose moral principles have withstood a horrific war, wants to report the man and infant immediately\"\n }\n]", "invoking_actions": [{"name": "search", "arguments": {}, "id": "2c22b0f4-7e6d-443b-a46f-d7e3ff465bca", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 3.30073, "benchmark_cost": 0.0} +{"event": "action", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 21, "action": {"name": "search", "arguments": {"query": "\"Storm and Silence\" ending Ile Marbeau island sea rescue Ambrose saves Lilly climax"}, "id": "7f1241b2-d1a6-40ee-a157-07e9f09e9d17", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 3.644375, "benchmark_cost": 0.0} +{"event": "observation", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 21, "observation": {"result": "[\n {\n \"docid\": \"90027\",\n \"score\": 0.550203800201416,\n \"snippet\": \"---\\ntitle: Storm and Silence\\ndate: 2014-04-02\\n---\\n| 'Blast, blast, blast!'\\n\\nThis article is a stub and thus, inadequate. Help Storm and Silence Wiki by expanding it. |\\n\\nStorm and Silence is the first novel in the eponymous series written by Robert Thier. It is free to read on the online community Wattpad and the Radish Fiction Mobile App. The first chapter was uploaded onto Wattpad on April 2014 and the novel was published electronically and in paperback on March 2016. It won the 2015 Award for Story of the Year and currently has a massive fanbase, with over 100 million reads on Wattpad.\\n\\nSynopsis[]\\n\\nWattpad Version[]\\n\\n\\\"It is your choice,\\\" he said, stepping so close to me that our lips were almost touching.\\n\\n\\\"Either do what I say - or get another job.\\\"\\n\\nMy heart stood still as I gazed up into his deep, dark, dangerous eyes...\\n\\nIn a world where women's only role in life is to sit at home and look pretty, Lilly is determined to fight for her freedom. There's only one problem: a powerful man blocking her way. [1]\\n\\nRadish/Published Version[]\\n\\nFreedom \\u2013 that is what Lilly Linton wants most in life. Not marriage, not a brood of squalling brats, and certainly not love, thank you very much!\\n\\nBut freedom is a rare commodity in 19th-century London, where girls are expected to spend their lives sitting at home, fully occupied with looking pretty. Lilly is at her wits' end \\u2013 until a chance encounter with a dark, dangerous and powerful stranger changes her life forever...[2]\\n\\nPlot[]\\n\\nTBA\\n\\nChapters[]\\n\\nWattpad Version[]\\n\\n- Arrested for Good Manners\\n\\n- Ape Bobby\\n\\n- Who He Really Is\\n\\n- Sweet and Solid\\n\\n- Driving Me Wild(ly)\\n\\n- Empire House\\n\\n- His Indecent Demands\\n\\n- Inventing a Sibling and Getting Poked in the Eye\\n\\n- File Fight\\n\\n- The Worst Fate Imaginable\\n\\n- The Dragon's Den\\n\\n- Practicing Impertinence\\n\\n- Ballroom Battle\\n\\n- The Sins of Mr Rikkard Ambrose\\n\\n- It gets mushy-gushy\\n\\n- Unsuitable Suitors\\n\\n- Return to the Game\\n\\n- The Peril of Flowers\\n\\n- The\"\n },\n {\n \"docid\": \"29859\",\n \"score\": 0.4979217052459717,\n \"snippet\": \"---\\ntitle: The Tides of Time\\nauthor: Sarah M Eden this is a placeholder\\ndate: 2025-01-01\\n---\\nWhat do you think?\\n\\nRate this book\\n\\nIn 1793, a storm propels Lili forward through time, kindling a love that transcends the ages.\\n\\nFleeing the clutches of Robespierre's revolutionary Tribunal in France, Lili Minet makes a desperate escape on a ship headed to England, but her dangerous flight takes an unexpected turn when a violent storm catapults her off the ship and eighty years into the future, leaving her stranded in the unfamiliar world of 1873 England.\\n\\nWhen lighthouse keeper Armitage Pierce rescues a woman from the tumultuous sea, he does not anticipate her silent, cold response to his gallant efforts. Though he is wary of this woman and her odd behaviors, he finds she is just as wary of him. And he can sense that she is not telling him something of great import to her. Only Armitage's grandfather, a man seasoned by the mysteries of the sea, can seem to penetrate Lili's defenses to offer her support. But as Lili heals from the physical and emotional wounds of her ordeal and Armitage continues to offer light and safety to her, a tender friendship blossoms between the two.\\n\\nYet the shadow of danger looms as the threat that chased Lili from France all those years ago reemerges in her new present. Together Lili and Armitage must navigate the challenges of a romance that grows to defy the boundaries of time and the perils that reach across the decades to ensnare Lili. As the storm clouds gather, Lili and Armitage face the ultimate test\\u2014discovering whether their bond is strong enough to rewrite the pages of history itself to save them and their love.\\n\\nFleeing the clutches of Robespierre's revolutionary Tribunal in France, Lili Minet makes a desperate escape on a ship headed to England, but her dangerous flight takes an unexpected turn when a violent storm catapults her off the ship and eighty years into the future, leaving her stranded in the unfamiliar world of 1873 England.\\n\\nWhen lighthouse keeper Armitage Pierce rescues a woman from the tumultuous sea, he does not anticipate her silent, cold response to his gallant efforts. Though he is wary of this woman and her odd behaviors, he finds she is just as wary of him. And\"\n },\n {\n \"docid\": \"25654\",\n \"score\": 0.48678192496299744,\n \"snippet\": \"---\\ntitle: Storm and Silence (Book Series)\\ndate: 2015-08-12\\n---\\nStorm and Silence is a historical fiction and romance book series written by Robert Thier. As of 2018, there are eight books in the entire series canonically. The series has gained extensive popularity online, with over one hundred million cumulative reads on the online community Wattpad and over six million reads on the Radish Fiction App.\\n\\nThe story follows the adventures of Lilly Linton, a fiery feminist living in 19th-century London, determined to earn her freedom and independence when her life is turned upside down after a chance encounter with the ruthless financial business magnate, Mr Ambrose.\\n\\nBooks\\n\\nMain series\\n\\nCurrently, there are six books in the main series. For each book initially, the chapters are individually uploaded on a weekly basis onto Radish and then Wattpad. When completed, they are edited and published as an individual book both electronically and in paperback. However, the first book is an exception to this as chapters were uploaded to Wattpad only at the time and then the book was self-published since the author did not start using Radish until the second book.\\n\\nThe following is a chronological list of the main books formatted as: Title (year initially uploaded online | year officially published).\\n\\n- Storm and Silence (2014 | 2016)\\n\\n- In the Eye of the Storm (2016 | 2016)\\n\\n- Silence is Golden (2016 | 2017)\\n\\n- Silence Breaking (2017 | 2018)\\n\\n- Hunting for Silence (2018 | TBA)\\n\\n- Storm of Bells (2018 | TBA)\\n\\n- New Storm Rising (2020 | TBA)\\n\\n- Storm over the Caribbean\\n\\n- Silence no More\\n\\nPrequel\\n\\nThe prequel takes place shortly before the beginning of the first book in the main series. It is a stand-alone novella that was commissioned by Radish Fiction and is exclusively on the mobile app.\\n\\n- Before the Storm (2016 | \\u2014)\\n\\nSide story\\n\\nThe following work takes place in between the second and third book of the main series. It was written in thanks from the author to the fanbase for voting for the first book from the main series in the Romance category for the 2016 Goodreads Choice Awards.\\n\\n- Silent Night (2016 | \\u2014\"\n },\n {\n \"docid\": \"72764\",\n \"score\": 0.4727753698825836,\n \"snippet\": \"---\\ntitle: Overboard!: A True Blue-water Odyssey of Disaster and Survival Hardcover \\u2013 March 16, 2010\\nauthor: Michael J Tougias\\ndate: 2010-03-16\\n---\\n-24% $18.15$18.15\\n\\nDelivery Sunday, June 15\\n\\nShips from: Amazon Sold by: Bookdream\\n\\n$13.79$13.79\\n\\nDelivery Thursday, June 19\\n\\nShips from: Amazon Sold by: Zoom Books Company\\n\\nReturn this item for free\\n\\nFree returns are available for the shipping address you chose. You can return the item for any reason in new and unused condition: no return shipping charges.\\n\\nLearn more about free returns.- Go to your orders and start the return\\n\\n- Select your preferred free shipping option\\n\\n- Drop off and leave!\\n\\nSorry, there was a problem.\\n\\nThere was an error retrieving your Wish Lists. Please try again.Sorry, there was a problem.\\n\\nList unavailable.Download the free Kindle app and start reading Kindle books instantly on your smartphone, tablet, or computer - no Kindle device required.\\n\\nRead instantly on your browser with Kindle for Web.\\n\\nUsing your mobile phone camera - scan the code below and download the Kindle app.\\n\\nAudible sample\\n\\nFollow the author\\n\\nOK\\n\\nOverboard!: A True Blue-water Odyssey of Disaster and Survival Hardcover \\u2013 March 16, 2010\\n\\nPurchase options and add-ons\\n\\nIn May 2005, Tom Tighe, captain of a forty-five-foot-long sailboat named the Almeisan, and his first mate, Loch Reidy, welcomed three new crewmembers for a five-day voyage from Connecticut across the blue waters of the Gulf Stream to sun-drenched Bermuda. The new crew included forty-six-year-old Kathy Gilchrist, seventy-year-old Ron Burd, and thirty-four-year-old Chris Ferrer. Although Tighe had made the trip forty-eight times, with Reidy accompanying him on twenty of those voyages, the rest of the crew had joined to learn more about offshore sailing.\\n\\nFour days into the voyage, an enormous storm struck, sweeping two of the crew into the towering sea. The remaining crewmembers managed to stay aboard the vessel as it was slowly torn apart by the rampaging ocean. Overboard! follows the simultaneous desperate struggles of both those still on the boat and those fighting for their lives in the sea.\\n\\nThe Coast Guard, alerted to the Almeisan's\"\n },\n {\n \"docid\": \"71659\",\n \"score\": 0.45753324031829834,\n \"snippet\": \"---\\ntitle: REVIEW: Storm and Silence by Robert Thier\\nauthor: Nikka\\ndate: 2016-04-03\\n---\\nP A P E R & T R E E S\\nA Book Blog\\n\\nREVIEW: Storm and Silence by Robert Thier\\nA copy of this book was provided by the authorin exchange for an honest review. This does not affect my opinion of the book or the content of my review.\\n\\nSeries: Storm and Silence #1Publication Date: March 19 2016 by Robert Thier\\nGenres: Romance, Historical Fiction\\nNumber of Pages: 596\\nMy Rating: \\u2605\\u2605\\u2605\\u2729\\u2729\\nAmazon // Book Depository // iBooksGoodreads\\n\\nFreedom \\u2013 that is what Lilly Linton wants most in life. Not marriage, not a brood of squalling brats, and certainly not love, thank you very much!\\nBut freedom is a rare commodity in 19th-century London, where girls are expected to spend their lives sitting at home, fully occupied with looking pretty. Lilly is at her wits' end \\u2013 until a chance encounter with a dark, dangerous and powerful stranger changes her life forever\\u2026\\nEnter the world of Mr Rikkard Ambrose, where the only rule is: Knowledge is power is time is money!\\n\\nThe story is set in 19th-century London where women, although dressed beautifully and often treated as fragile flowers, do not have the liberty to pursue their own interests. From education to marriage to rights, women were oppressed and seen as naive and incapable of living on their own.\\nAs Tiffany Stelle retold, \\\"She was expected to keep the house clean, cook the meals, raise the children, decorate the house, and keep her children and husband on the moral high ground. When a husband came home from work he was expecting a smile on his wife, who is dressed perfectly, a house that is spotless and bright, and a forgiving happy environment that would lure him into wanting to return home each day.\\\"\\nBut for Lilly Linton, this is not the way things are supposed to be. She is a suffragette, a feminist, and she needs freedom as much as the next guy needs his daily dose of vanity. So she did what a feminist woman did during the Victorian era\\u2014she pretended to be a man. But unlike any other woman, she was offered a job and she took it, for this could be the key to the freedom she's longing for.\\nThis book\"\n }\n]", "invoking_actions": [{"name": "search", "arguments": {}, "id": "7f1241b2-d1a6-40ee-a157-07e9f09e9d17", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 3.644375, "benchmark_cost": 0.0} +{"event": "action", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 22, "action": {"name": "get_document", "arguments": {"docid": "25654"}, "id": "4e09f051-6c36-479f-a15c-cbdc65ec5c1c", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 4.004180000000001, "benchmark_cost": 0.0} +{"event": "observation", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 22, "observation": {"result": "{\n \"docid\": \"25654\",\n \"text\": \"---\\ntitle: Storm and Silence (Book Series)\\ndate: 2015-08-12\\n---\\nStorm and Silence is a historical fiction and romance book series written by Robert Thier. As of 2018, there are eight books in the entire series canonically. The series has gained extensive popularity online, with over one hundred million cumulative reads on the online community Wattpad and over six million reads on the Radish Fiction App.\\n\\nThe story follows the adventures of Lilly Linton, a fiery feminist living in 19th-century London, determined to earn her freedom and independence when her life is turned upside down after a chance encounter with the ruthless financial business magnate, Mr Ambrose.\\n\\nBooks\\n\\nMain series\\n\\nCurrently, there are six books in the main series. For each book initially, the chapters are individually uploaded on a weekly basis onto Radish and then Wattpad. When completed, they are edited and published as an individual book both electronically and in paperback. However, the first book is an exception to this as chapters were uploaded to Wattpad only at the time and then the book was self-published since the author did not start using Radish until the second book.\\n\\nThe following is a chronological list of the main books formatted as: Title (year initially uploaded online | year officially published).\\n\\n- Storm and Silence (2014 | 2016)\\n\\n- In the Eye of the Storm (2016 | 2016)\\n\\n- Silence is Golden (2016 | 2017)\\n\\n- Silence Breaking (2017 | 2018)\\n\\n- Hunting for Silence (2018 | TBA)\\n\\n- Storm of Bells (2018 | TBA)\\n\\n- New Storm Rising (2020 | TBA)\\n\\n- Storm over the Caribbean\\n\\n- Silence no More\\n\\nPrequel\\n\\nThe prequel takes place shortly before the beginning of the first book in the main series. It is a stand-alone novella that was commissioned by Radish Fiction and is exclusively on the mobile app.\\n\\n- Before the Storm (2016 | \\u2014)\\n\\nSide story\\n\\nThe following work takes place in between the second and third book of the main series. It was written in thanks from the author to the fanbase for voting for the first book from the main series in the Romance category for the 2016 Goodreads Choice Awards.\\n\\n- Silent Night (2016 | \\u2014)\\n\\nSpin-off\\n\\nA spin-off story takes place after the seventh book of the main series. It focuses on the adventure of Lilly's friend, Amy Weston, and Lord Patrick Day.\\n\\n- Lord Day and Lady Night (2019 | \\u2014)\\n\\nAlternative spin-off\\n\\nSometime while Storm and Silence was being written, the author was offered an opportunity to write a spin-off story in an alternate reality by Lionsgate Films to promote its film, The Age of Adaline. The story is set within the theme of the film and is non-canon in regards to the main series.\\n\\n- Up and Down (2015 | \\u2014)\\n\\nTrivia\\n\\n- The inspiration for the series stemmed from the Suffragist and Chartist movements in the 19th-century that the author extensively studied during university.[1]\\n\\n- Publishers did not want to publish the first book of the main series as they thought it was too long. Thus, the author decided to self-publish and was able to cover publication costs through crowdfunding on Indiegogo.[2] The subsequent books in the main series followed suit and have also been self-published.\\n\\nGallery\\n\\nThe front covers of the books from the main series, featuring Mr Ambrose.\\n\\nReferences\\n\\n- \\u2191 \\n\\n- \\u2191 \"\n}", "invoking_actions": [{"name": "get_document", "arguments": {}, "id": "4e09f051-6c36-479f-a15c-cbdc65ec5c1c", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 4.004180000000001, "benchmark_cost": 0.0} +{"event": "action", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 23, "action": {"name": "search", "arguments": {"query": "\"Storm and Silence\" detailed plot summary Ambrose Lilly document Dalgliesh encrypted file stolen accusation France abroad"}, "id": "07322661-e5a0-4fdd-8e07-7a7c83563108", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 4.369885000000001, "benchmark_cost": 0.0} +{"event": "observation", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 23, "observation": {"result": "[\n {\n \"docid\": \"90027\",\n \"score\": 0.5491895079612732,\n \"snippet\": \"---\\ntitle: Storm and Silence\\ndate: 2014-04-02\\n---\\n| 'Blast, blast, blast!'\\n\\nThis article is a stub and thus, inadequate. Help Storm and Silence Wiki by expanding it. |\\n\\nStorm and Silence is the first novel in the eponymous series written by Robert Thier. It is free to read on the online community Wattpad and the Radish Fiction Mobile App. The first chapter was uploaded onto Wattpad on April 2014 and the novel was published electronically and in paperback on March 2016. It won the 2015 Award for Story of the Year and currently has a massive fanbase, with over 100 million reads on Wattpad.\\n\\nSynopsis[]\\n\\nWattpad Version[]\\n\\n\\\"It is your choice,\\\" he said, stepping so close to me that our lips were almost touching.\\n\\n\\\"Either do what I say - or get another job.\\\"\\n\\nMy heart stood still as I gazed up into his deep, dark, dangerous eyes...\\n\\nIn a world where women's only role in life is to sit at home and look pretty, Lilly is determined to fight for her freedom. There's only one problem: a powerful man blocking her way. [1]\\n\\nRadish/Published Version[]\\n\\nFreedom \\u2013 that is what Lilly Linton wants most in life. Not marriage, not a brood of squalling brats, and certainly not love, thank you very much!\\n\\nBut freedom is a rare commodity in 19th-century London, where girls are expected to spend their lives sitting at home, fully occupied with looking pretty. Lilly is at her wits' end \\u2013 until a chance encounter with a dark, dangerous and powerful stranger changes her life forever...[2]\\n\\nPlot[]\\n\\nTBA\\n\\nChapters[]\\n\\nWattpad Version[]\\n\\n- Arrested for Good Manners\\n\\n- Ape Bobby\\n\\n- Who He Really Is\\n\\n- Sweet and Solid\\n\\n- Driving Me Wild(ly)\\n\\n- Empire House\\n\\n- His Indecent Demands\\n\\n- Inventing a Sibling and Getting Poked in the Eye\\n\\n- File Fight\\n\\n- The Worst Fate Imaginable\\n\\n- The Dragon's Den\\n\\n- Practicing Impertinence\\n\\n- Ballroom Battle\\n\\n- The Sins of Mr Rikkard Ambrose\\n\\n- It gets mushy-gushy\\n\\n- Unsuitable Suitors\\n\\n- Return to the Game\\n\\n- The Peril of Flowers\\n\\n- The\"\n },\n {\n \"docid\": \"25654\",\n \"score\": 0.5177220702171326,\n \"snippet\": \"---\\ntitle: Storm and Silence (Book Series)\\ndate: 2015-08-12\\n---\\nStorm and Silence is a historical fiction and romance book series written by Robert Thier. As of 2018, there are eight books in the entire series canonically. The series has gained extensive popularity online, with over one hundred million cumulative reads on the online community Wattpad and over six million reads on the Radish Fiction App.\\n\\nThe story follows the adventures of Lilly Linton, a fiery feminist living in 19th-century London, determined to earn her freedom and independence when her life is turned upside down after a chance encounter with the ruthless financial business magnate, Mr Ambrose.\\n\\nBooks\\n\\nMain series\\n\\nCurrently, there are six books in the main series. For each book initially, the chapters are individually uploaded on a weekly basis onto Radish and then Wattpad. When completed, they are edited and published as an individual book both electronically and in paperback. However, the first book is an exception to this as chapters were uploaded to Wattpad only at the time and then the book was self-published since the author did not start using Radish until the second book.\\n\\nThe following is a chronological list of the main books formatted as: Title (year initially uploaded online | year officially published).\\n\\n- Storm and Silence (2014 | 2016)\\n\\n- In the Eye of the Storm (2016 | 2016)\\n\\n- Silence is Golden (2016 | 2017)\\n\\n- Silence Breaking (2017 | 2018)\\n\\n- Hunting for Silence (2018 | TBA)\\n\\n- Storm of Bells (2018 | TBA)\\n\\n- New Storm Rising (2020 | TBA)\\n\\n- Storm over the Caribbean\\n\\n- Silence no More\\n\\nPrequel\\n\\nThe prequel takes place shortly before the beginning of the first book in the main series. It is a stand-alone novella that was commissioned by Radish Fiction and is exclusively on the mobile app.\\n\\n- Before the Storm (2016 | \\u2014)\\n\\nSide story\\n\\nThe following work takes place in between the second and third book of the main series. It was written in thanks from the author to the fanbase for voting for the first book from the main series in the Romance category for the 2016 Goodreads Choice Awards.\\n\\n- Silent Night (2016 | \\u2014\"\n },\n {\n \"docid\": \"79197\",\n \"score\": 0.49903059005737305,\n \"snippet\": \"---\\ntitle: Mysterious Circumstances\\nauthor: David Grann\\ndate: 2004-12-13\\n---\\nRichard Lancelyn Green, the world's foremost expert on Sherlock Holmes, believed that he had finally solved the case of the missing papers. Over the past two decades, he had been looking for a trove of letters, diary entries, and manuscripts written by Sir Arthur Conan Doyle, the creator of Holmes. The archive was estimated to be worth nearly four million dollars, and was said by some to carry a deadly curse, like the one in the most famous Holmes story \\\"The Hound of the Baskervilles.\\\"\\n\\nThe papers had disappeared after Conan Doyle died, in 1930, and without them no one had been able to write a definitive biography\\u2014a task that Green was determined to complete. Many scholars feared that the archive had been discarded or destroyed; as the London Times noted earlier this year, its whereabouts had become \\\"a mystery as tantalizing as any to unfold at 221B Baker Street,\\\" the fictional den of Holmes and his fellow-sleuth, Dr. Watson.\\n\\nNot long after Green launched his investigation, he discovered that one of Conan Doyle's five children, Adrian, had, with the other heirs' agreement, stashed the papers in a locked room of a ch\\u00e2teau that he owned in Switzerland. Green then learned that Adrian had spirited some of the papers out of the ch\\u00e2teau without his siblings' knowledge, hoping to sell them to collectors. In the midst of this scheme, he died of a heart attack\\u2014giving rise to the legend of the curse. After Adrian's death, the papers apparently vanished. And whenever Green tried to probe further he found himself caught in an impenetrable web of heirs\\u2014including a self-styled Russian princess\\u2014who seemed to have deceived and double-crossed each other in their efforts to control the archive.\\n\\nFor years, Green continued to sort through evidence and interview relatives, until one day the muddled trail led to London\\u2014and the doorstep of Jean Conan Doyle, the youngest of the author's children. Tall and elegant, with silver hair, she was an imposing woman in her late sixties. (\\\"Something very strong and forceful seems to be at the back of that wee body,\\\" her father had written of Jean when she was five. \\\"Her will is tremendous.\\\") Whereas her brother Adrian had been kicked out of the British Navy for insubordination, and her elder brother Denis was a playboy who had\"\n },\n {\n \"docid\": \"71659\",\n \"score\": 0.4766990840435028,\n \"snippet\": \"---\\ntitle: REVIEW: Storm and Silence by Robert Thier\\nauthor: Nikka\\ndate: 2016-04-03\\n---\\nP A P E R & T R E E S\\nA Book Blog\\n\\nREVIEW: Storm and Silence by Robert Thier\\nA copy of this book was provided by the authorin exchange for an honest review. This does not affect my opinion of the book or the content of my review.\\n\\nSeries: Storm and Silence #1Publication Date: March 19 2016 by Robert Thier\\nGenres: Romance, Historical Fiction\\nNumber of Pages: 596\\nMy Rating: \\u2605\\u2605\\u2605\\u2729\\u2729\\nAmazon // Book Depository // iBooksGoodreads\\n\\nFreedom \\u2013 that is what Lilly Linton wants most in life. Not marriage, not a brood of squalling brats, and certainly not love, thank you very much!\\nBut freedom is a rare commodity in 19th-century London, where girls are expected to spend their lives sitting at home, fully occupied with looking pretty. Lilly is at her wits' end \\u2013 until a chance encounter with a dark, dangerous and powerful stranger changes her life forever\\u2026\\nEnter the world of Mr Rikkard Ambrose, where the only rule is: Knowledge is power is time is money!\\n\\nThe story is set in 19th-century London where women, although dressed beautifully and often treated as fragile flowers, do not have the liberty to pursue their own interests. From education to marriage to rights, women were oppressed and seen as naive and incapable of living on their own.\\nAs Tiffany Stelle retold, \\\"She was expected to keep the house clean, cook the meals, raise the children, decorate the house, and keep her children and husband on the moral high ground. When a husband came home from work he was expecting a smile on his wife, who is dressed perfectly, a house that is spotless and bright, and a forgiving happy environment that would lure him into wanting to return home each day.\\\"\\nBut for Lilly Linton, this is not the way things are supposed to be. She is a suffragette, a feminist, and she needs freedom as much as the next guy needs his daily dose of vanity. So she did what a feminist woman did during the Victorian era\\u2014she pretended to be a man. But unlike any other woman, she was offered a job and she took it, for this could be the key to the freedom she's longing for.\\nThis book\"\n },\n {\n \"docid\": \"66065\",\n \"score\": 0.4661906063556671,\n \"snippet\": \"---\\ntitle: James Patterson \\u2013 Books \\u2013 Standalone Thrillers\\ndate: 1996-12-01\\n---\\nJames Patterson \\u2013 Books \\u2013 Standalone Thrillers\\n\\nSTANDALONE THRILLERS\\n\\nTitles List\\n\\n-\\n\\nA $100 million painting. A previously unknown Picasso is discovered in the attic of a French villa. Everyone wants to possess it. Filthy-rich Manhattan art people. Organized crime bosses. Power-hungry government officials. A notorious forger. A glamorous twenty-two-year-old art thief. Only one person\\u2026 Read More\\n\\nLittle, Brown and Company -\\n\\nFrom #1 New York Times bestselling author James Patterson, six kidnappers pursue a whopping ten-figure payout in this thrilling novel featuring \\\"breakneck pacing and loop-the-loop plotting\\\" (Publishers Weekly). Five members of a billionaire's family. In different locations. All kidnapped at\\u2026 Read More\\n\\nLittle, Brown and Company -\\n\\n\\\"Bill Clinton reveals key White House details in murderous new political thriller\\u2026 It's a twisty thriller with plenty of inside jobs, political sabotage and many, many deaths.\\\" \\u2013USA TodayAmerica has a powerful new president\\u2026 And her husband's on trial for murder. Clinton\\u2026 Read More\\n\\nLittle, Brown and Company -\\n\\n\\\"Consider blocking out a few hours of uninterrupted reading time\\\" for The Writer, #1 New York Times bestselling author James Patterson's \\\"Excellent\\u2026perfectly executed\\u2026genuinely suspenseful\\\" (Booklist) thriller about a true-crime author swept up in a murder plot.\\\"Entertaining\\u2026one gonzo plot twist follows the next\\u2026loads of fun.\\\" (Publishers Weekly)\\u2026 Read More\\n\\nLittle, Brown and Company -\\n\\nThe police are called to stop two teens ransacking a small-town grocery store. They have no identification. And they won't speak a word. And the small town of Kokanee Creek is suddenly plunged into a sinister mystery that only #1 bestseller James Patterson\\u2026 Read More\\n\\nLittle, Brown and Company -\\n\\nIn this thrilling novel from the authors of the bestselling Death of the Black Widow, the police of a small New England town are confronted with a baffling series of crimes. Hollows Bend, New Hampshire, is a picture-perfect New England\\u2026 Read More\\n\\nLittle, Brown and Company -\\n\\nIt's the perfect getaway. But the past will always find you . . . When ex-cop Bill Robinson takes over The Inn by the Sea, all he wants is a quiet escape from the city. But when a crime boss\"\n }\n]", "invoking_actions": [{"name": "search", "arguments": {}, "id": "07322661-e5a0-4fdd-8e07-7a7c83563108", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 4.369885000000001, "benchmark_cost": 0.0} +{"event": "action", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 24, "action": {"name": "get_document", "arguments": {"docid": "71659"}, "id": "700da1df-4a6c-4d71-b05b-f6f07ae028f8", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 4.753415, "benchmark_cost": 0.0} +{"event": "observation", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 24, "observation": {"result": "{\n \"docid\": \"71659\",\n \"text\": \"---\\ntitle: REVIEW: Storm and Silence by Robert Thier\\nauthor: Nikka\\ndate: 2016-04-03\\n---\\nP A P E R & T R E E S\\nA Book Blog\\n\\nREVIEW: Storm and Silence by Robert Thier\\nA copy of this book was provided by the authorin exchange for an honest review. This does not affect my opinion of the book or the content of my review.\\n\\nSeries: Storm and Silence #1Publication Date: March 19 2016 by Robert Thier\\nGenres: Romance, Historical Fiction\\nNumber of Pages: 596\\nMy Rating: \\u2605\\u2605\\u2605\\u2729\\u2729\\nAmazon // Book Depository // iBooksGoodreads\\n\\nFreedom \\u2013 that is what Lilly Linton wants most in life. Not marriage, not a brood of squalling brats, and certainly not love, thank you very much!\\nBut freedom is a rare commodity in 19th-century London, where girls are expected to spend their lives sitting at home, fully occupied with looking pretty. Lilly is at her wits' end \\u2013 until a chance encounter with a dark, dangerous and powerful stranger changes her life forever\\u2026\\nEnter the world of Mr Rikkard Ambrose, where the only rule is: Knowledge is power is time is money!\\n\\nThe story is set in 19th-century London where women, although dressed beautifully and often treated as fragile flowers, do not have the liberty to pursue their own interests. From education to marriage to rights, women were oppressed and seen as naive and incapable of living on their own.\\nAs Tiffany Stelle retold, \\\"She was expected to keep the house clean, cook the meals, raise the children, decorate the house, and keep her children and husband on the moral high ground. When a husband came home from work he was expecting a smile on his wife, who is dressed perfectly, a house that is spotless and bright, and a forgiving happy environment that would lure him into wanting to return home each day.\\\"\\nBut for Lilly Linton, this is not the way things are supposed to be. She is a suffragette, a feminist, and she needs freedom as much as the next guy needs his daily dose of vanity. So she did what a feminist woman did during the Victorian era\\u2014she pretended to be a man. But unlike any other woman, she was offered a job and she took it, for this could be the key to the freedom she's longing for.\\nThis book has a really interesting concept. The thing that got my attention most is Lilly being a feminist. I, aware of the differences in gender roles between today and the past, was really intrigued. However, I thought there were many unnecessary parts that made the book too long, and sometimes exhausting to read. While I understand that the book was originally published on Wattpad (which means that at least a chapter is published every other day or depending on the author's preferred schedule), I thought it was repetitive and still needs more editing for the final copy. I have to admit that I skipped a lot of pages (I'm allowed to, right?) but didn't miss anything important.\\nI also find the main character nosy and irrational at some points. For someone claiming to be mature, she acts like a little kid whose candies got stolen! Her persistence could be a little too much and just makes her look stubborn, not strong.\\nIt took me a while before getting hooked. Actually, based on my notes, that's 800/2659 pages in an eBook. WHICH IS A LOT.\\nI also appreciate the writer's attention to detail and the book being historically accurate, plus the annotations were really helpful (and cool) because you get to learn stuff about history.\\nThis book is very promising, but I thought it still has lots of room for improvement. The ending was also so disappointing, at the end I was like\\u2026 that's it?I mean, I know there's a sequel coming up, but a first book should make you feel satisfied and keen to read the next one! This book didn't. However, I would still read the second book (because I'm a rebel) in hopes of getting answers to my questions, if and only if it's 400 pages and below. Ha.\\nShare this:\\n\\nTweet\\nShare on Tumblr\\nClick to email a link to a friend (Opens in new window)\\n Email\\n\\nLike Loading...\\nRelated\\n\\nFreedom \\u2013 that is what Lilly Linton wants most in life. Not marriage, not a brood of squalling brats, and certainly not love, thank you very much!\\nBut freedom is a rare commodity in 19th-century London, where girls are expected to spend their lives sitting at home, fully occupied with looking pretty. Lilly is at her wits' end \\u2013 until a chance encounter with a dark, dangerous and powerful stranger changes her life forever\\u2026\\nEnter the world of Mr Rikkard Ambrose, where the only rule is: Knowledge is power is time is money!\\n\\nThe story is set in 19th-century London where women, although dressed beautifully and often treated as fragile flowers, do not have the liberty to pursue their own interests. From education to marriage to rights, women were oppressed and seen as naive and incapable of living on their own.\\nAs Tiffany Stelle retold, \\\"She was expected to keep the house clean, cook the meals, raise the children, decorate the house, and keep her children and husband on the moral high ground. When a husband came home from work he was expecting a smile on his wife, who is dressed perfectly, a house that is spotless and bright, and a forgiving happy environment that would lure him into wanting to return home each day.\\\"\\nBut for Lilly Linton, this is not the way things are supposed to be. She is a suffragette, a feminist, and she needs freedom as much as the next guy needs his daily dose of vanity. So she did what a feminist woman did during the Victorian era\\u2014she pretended to be a man. But unlike any other woman, she was offered a job and she took it, for this could be the key to the freedom she's longing for.\\nThis book has a really interesting concept. The thing that got my attention most is Lilly being a feminist. I, aware of the differences in gender roles between today and the past, was really intrigued. However, I thought there were many unnecessary parts that made the book too long, and sometimes exhausting to read. While I understand that the book was originally published on Wattpad (which means that at least a chapter is published every other day or depending on the author's preferred schedule), I thought it was repetitive and still needs more editing for the final copy. I have to admit that I skipped a lot of pages (I'm allowed to, right?) but didn't miss anything important.\\nI also find the main character nosy and irrational at some points. For someone claiming to be mature, she acts like a little kid whose candies got stolen! Her persistence could be a little too much and just makes her look stubborn, not strong.\\nIt took me a while before getting hooked. Actually, based on my notes, that's 800/2659 pages in an eBook. WHICH IS A LOT.\\nI also appreciate the writer's attention to detail and the book being historically accurate, plus the annotations were really helpful (and cool) because you get to learn stuff about history.\\nThis book is very promising, but I thought it still has lots of room for improvement. The ending was also so disappointing, at the end I was like\\u2026 that's it?I mean, I know there's a sequel coming up, but a first book should make you feel satisfied and keen to read the next one! This book didn't. However, I would still read the second book (because I'm a rebel) in hopes of getting answers to my questions, if and only if it's 400 pages and below. Ha.\\nShare this:\\n\\nTweet\\nShare on Tumblr\\nClick to email a link to a friend (Opens in new window)\\n Email\\n\\nLike Loading...\\nRelated\\n\\nFreedom \\u2013 that is what Lilly Linton wants most in life. Not marriage, not a brood of squalling brats, and certainly not love, thank you very much!\\nBut freedom is a rare commodity in 19th-century London, where girls are expected to spend their lives sitting at home, fully occupied with looking pretty. Lilly is at her wits' end \\u2013 until a chance encounter with a dark, dangerous and powerful stranger changes her life forever\\u2026\\nEnter the world of Mr Rikkard Ambrose, where the only rule is: Knowledge is power is time is money!\\n\\nThe story is set in 19th-century London where women, although dressed beautifully and often treated as fragile flowers, do not have the liberty to pursue their own interests. From education to marriage to rights, women were oppressed and seen as naive and incapable of living on their own.\\nAs Tiffany Stelle retold, \\\"She was expected to keep the house clean, cook the meals, raise the children, decorate the house, and keep her children and husband on the moral high ground. When a husband came home from work he was expecting a smile on his wife, who is dressed perfectly, a house that is spotless and bright, and a forgiving happy environment that would lure him into wanting to return home each day.\\\"\\nBut for Lilly Linton, this is not the way things are supposed to be. She is a suffragette, a feminist, and she needs freedom as much as the next guy needs his daily dose of vanity. So she did what a feminist woman did during the Victorian era\\u2014she pretended to be a man. But unlike any other woman, she was offered a job and she took it, for this could be the key to the freedom she's longing for.\\nThis book has a really interesting concept. The thing that got my attention most is Lilly being a feminist\"\n}", "invoking_actions": [{"name": "get_document", "arguments": {}, "id": "700da1df-4a6c-4d71-b05b-f6f07ae028f8", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 4.753415, "benchmark_cost": 0.0} +{"event": "action", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 25, "action": {"name": "search", "arguments": {"query": "\"Storm and Silence\" review book full summary stolen file Lilly secretary Ambrose Dalgliesh France storm sea rescue"}, "id": "da4e4b42-8b56-440e-97a9-b0f74486530b", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 5.149675000000001, "benchmark_cost": 0.0} +{"event": "observation", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 25, "observation": {"result": "[\n {\n \"docid\": \"90027\",\n \"score\": 0.574070930480957,\n \"snippet\": \"---\\ntitle: Storm and Silence\\ndate: 2014-04-02\\n---\\n| 'Blast, blast, blast!'\\n\\nThis article is a stub and thus, inadequate. Help Storm and Silence Wiki by expanding it. |\\n\\nStorm and Silence is the first novel in the eponymous series written by Robert Thier. It is free to read on the online community Wattpad and the Radish Fiction Mobile App. The first chapter was uploaded onto Wattpad on April 2014 and the novel was published electronically and in paperback on March 2016. It won the 2015 Award for Story of the Year and currently has a massive fanbase, with over 100 million reads on Wattpad.\\n\\nSynopsis[]\\n\\nWattpad Version[]\\n\\n\\\"It is your choice,\\\" he said, stepping so close to me that our lips were almost touching.\\n\\n\\\"Either do what I say - or get another job.\\\"\\n\\nMy heart stood still as I gazed up into his deep, dark, dangerous eyes...\\n\\nIn a world where women's only role in life is to sit at home and look pretty, Lilly is determined to fight for her freedom. There's only one problem: a powerful man blocking her way. [1]\\n\\nRadish/Published Version[]\\n\\nFreedom \\u2013 that is what Lilly Linton wants most in life. Not marriage, not a brood of squalling brats, and certainly not love, thank you very much!\\n\\nBut freedom is a rare commodity in 19th-century London, where girls are expected to spend their lives sitting at home, fully occupied with looking pretty. Lilly is at her wits' end \\u2013 until a chance encounter with a dark, dangerous and powerful stranger changes her life forever...[2]\\n\\nPlot[]\\n\\nTBA\\n\\nChapters[]\\n\\nWattpad Version[]\\n\\n- Arrested for Good Manners\\n\\n- Ape Bobby\\n\\n- Who He Really Is\\n\\n- Sweet and Solid\\n\\n- Driving Me Wild(ly)\\n\\n- Empire House\\n\\n- His Indecent Demands\\n\\n- Inventing a Sibling and Getting Poked in the Eye\\n\\n- File Fight\\n\\n- The Worst Fate Imaginable\\n\\n- The Dragon's Den\\n\\n- Practicing Impertinence\\n\\n- Ballroom Battle\\n\\n- The Sins of Mr Rikkard Ambrose\\n\\n- It gets mushy-gushy\\n\\n- Unsuitable Suitors\\n\\n- Return to the Game\\n\\n- The Peril of Flowers\\n\\n- The\"\n },\n {\n \"docid\": \"25654\",\n \"score\": 0.5394290685653687,\n \"snippet\": \"---\\ntitle: Storm and Silence (Book Series)\\ndate: 2015-08-12\\n---\\nStorm and Silence is a historical fiction and romance book series written by Robert Thier. As of 2018, there are eight books in the entire series canonically. The series has gained extensive popularity online, with over one hundred million cumulative reads on the online community Wattpad and over six million reads on the Radish Fiction App.\\n\\nThe story follows the adventures of Lilly Linton, a fiery feminist living in 19th-century London, determined to earn her freedom and independence when her life is turned upside down after a chance encounter with the ruthless financial business magnate, Mr Ambrose.\\n\\nBooks\\n\\nMain series\\n\\nCurrently, there are six books in the main series. For each book initially, the chapters are individually uploaded on a weekly basis onto Radish and then Wattpad. When completed, they are edited and published as an individual book both electronically and in paperback. However, the first book is an exception to this as chapters were uploaded to Wattpad only at the time and then the book was self-published since the author did not start using Radish until the second book.\\n\\nThe following is a chronological list of the main books formatted as: Title (year initially uploaded online | year officially published).\\n\\n- Storm and Silence (2014 | 2016)\\n\\n- In the Eye of the Storm (2016 | 2016)\\n\\n- Silence is Golden (2016 | 2017)\\n\\n- Silence Breaking (2017 | 2018)\\n\\n- Hunting for Silence (2018 | TBA)\\n\\n- Storm of Bells (2018 | TBA)\\n\\n- New Storm Rising (2020 | TBA)\\n\\n- Storm over the Caribbean\\n\\n- Silence no More\\n\\nPrequel\\n\\nThe prequel takes place shortly before the beginning of the first book in the main series. It is a stand-alone novella that was commissioned by Radish Fiction and is exclusively on the mobile app.\\n\\n- Before the Storm (2016 | \\u2014)\\n\\nSide story\\n\\nThe following work takes place in between the second and third book of the main series. It was written in thanks from the author to the fanbase for voting for the first book from the main series in the Romance category for the 2016 Goodreads Choice Awards.\\n\\n- Silent Night (2016 | \\u2014\"\n },\n {\n \"docid\": \"29859\",\n \"score\": 0.5252851247787476,\n \"snippet\": \"---\\ntitle: The Tides of Time\\nauthor: Sarah M Eden this is a placeholder\\ndate: 2025-01-01\\n---\\nWhat do you think?\\n\\nRate this book\\n\\nIn 1793, a storm propels Lili forward through time, kindling a love that transcends the ages.\\n\\nFleeing the clutches of Robespierre's revolutionary Tribunal in France, Lili Minet makes a desperate escape on a ship headed to England, but her dangerous flight takes an unexpected turn when a violent storm catapults her off the ship and eighty years into the future, leaving her stranded in the unfamiliar world of 1873 England.\\n\\nWhen lighthouse keeper Armitage Pierce rescues a woman from the tumultuous sea, he does not anticipate her silent, cold response to his gallant efforts. Though he is wary of this woman and her odd behaviors, he finds she is just as wary of him. And he can sense that she is not telling him something of great import to her. Only Armitage's grandfather, a man seasoned by the mysteries of the sea, can seem to penetrate Lili's defenses to offer her support. But as Lili heals from the physical and emotional wounds of her ordeal and Armitage continues to offer light and safety to her, a tender friendship blossoms between the two.\\n\\nYet the shadow of danger looms as the threat that chased Lili from France all those years ago reemerges in her new present. Together Lili and Armitage must navigate the challenges of a romance that grows to defy the boundaries of time and the perils that reach across the decades to ensnare Lili. As the storm clouds gather, Lili and Armitage face the ultimate test\\u2014discovering whether their bond is strong enough to rewrite the pages of history itself to save them and their love.\\n\\nFleeing the clutches of Robespierre's revolutionary Tribunal in France, Lili Minet makes a desperate escape on a ship headed to England, but her dangerous flight takes an unexpected turn when a violent storm catapults her off the ship and eighty years into the future, leaving her stranded in the unfamiliar world of 1873 England.\\n\\nWhen lighthouse keeper Armitage Pierce rescues a woman from the tumultuous sea, he does not anticipate her silent, cold response to his gallant efforts. Though he is wary of this woman and her odd behaviors, he finds she is just as wary of him. And\"\n },\n {\n \"docid\": \"71659\",\n \"score\": 0.5212535262107849,\n \"snippet\": \"---\\ntitle: REVIEW: Storm and Silence by Robert Thier\\nauthor: Nikka\\ndate: 2016-04-03\\n---\\nP A P E R & T R E E S\\nA Book Blog\\n\\nREVIEW: Storm and Silence by Robert Thier\\nA copy of this book was provided by the authorin exchange for an honest review. This does not affect my opinion of the book or the content of my review.\\n\\nSeries: Storm and Silence #1Publication Date: March 19 2016 by Robert Thier\\nGenres: Romance, Historical Fiction\\nNumber of Pages: 596\\nMy Rating: \\u2605\\u2605\\u2605\\u2729\\u2729\\nAmazon // Book Depository // iBooksGoodreads\\n\\nFreedom \\u2013 that is what Lilly Linton wants most in life. Not marriage, not a brood of squalling brats, and certainly not love, thank you very much!\\nBut freedom is a rare commodity in 19th-century London, where girls are expected to spend their lives sitting at home, fully occupied with looking pretty. Lilly is at her wits' end \\u2013 until a chance encounter with a dark, dangerous and powerful stranger changes her life forever\\u2026\\nEnter the world of Mr Rikkard Ambrose, where the only rule is: Knowledge is power is time is money!\\n\\nThe story is set in 19th-century London where women, although dressed beautifully and often treated as fragile flowers, do not have the liberty to pursue their own interests. From education to marriage to rights, women were oppressed and seen as naive and incapable of living on their own.\\nAs Tiffany Stelle retold, \\\"She was expected to keep the house clean, cook the meals, raise the children, decorate the house, and keep her children and husband on the moral high ground. When a husband came home from work he was expecting a smile on his wife, who is dressed perfectly, a house that is spotless and bright, and a forgiving happy environment that would lure him into wanting to return home each day.\\\"\\nBut for Lilly Linton, this is not the way things are supposed to be. She is a suffragette, a feminist, and she needs freedom as much as the next guy needs his daily dose of vanity. So she did what a feminist woman did during the Victorian era\\u2014she pretended to be a man. But unlike any other woman, she was offered a job and she took it, for this could be the key to the freedom she's longing for.\\nThis book\"\n },\n {\n \"docid\": \"82350\",\n \"score\": 0.49659380316734314,\n \"snippet\": \"---\\ntitle: \\u2630OTHER BOOKS\\ndate: 2023-01-05\\n---\\nThe Sleeping and the Dead\\n\\nIn this vivid psychological suspense novel, a diving instructor makes a gruesome discovery in Cranwell Lake - the body of a teenager who has clearly been in the water for many years.\\n\\nDetective Peter Porteous is called to Cranwell Lake where the body of a teenager has been discovered. After trawling through the missing persons files, he comes to the conclusion that the corpse is Michael Grey, an enigmatic and secretive young man who was reported missing by his foster parents in 1972.\\n\\nThe news report that a body has been found leaves prison officer Hannah Morton in shock. Michael had been her boyfriend, and she had been with him the night he disappeared. And now the discovery is bringing back dreaded and long buried memories from her past ...\\n\\nThe Sleeping and the Dead was first published in the UK in 2001. It was reissued by Pan MacMillan in their 'Ann Cleeves Classic Crime' series on 5th January 2023. Order a copy via the publisher's website, or from any bookshop or library (ISBN: 978-1-5290-7051-4).\\n\\nThe audiobook, read by John Telfer, is available in a choice of formats from the Reading House, or as an audio download from Amazon.\\n\\nBurial of Ghosts\\n\\nFor Lizzie Bartholomew, a holiday in Morocco will change life forever. But not in the way she had hoped...\\n\\nLizzie had planned her trip to Marrakech as the perfect escape from her life - and her nightmares - in Northumberland. Abandoned as a baby, and having spent her childhood moving between foster homes, Lizzie certainly has much to escape from. And for Lizzie, Morocco is the exotic paradise that she had imagined. Especially when she finds herself on a bus sitting next to a fellow tourist, who is also travelling to fulfil his dreams.\\n\\nAfter a brief affair, Lizzie returns to England. In the days that follow, she is distracted by thoughts of her mysterious lover, hoping against hope that Philip might come and find her. But suddenly she receives a letter from a firm of solicitors. Philip Samson has died. In his will, he has left Lizzie a gift of \\u00a315,000. But there are conditions attached to this unexpected legacy, conditions that will alter the\"\n }\n]", "invoking_actions": [{"name": "search", "arguments": {}, "id": "da4e4b42-8b56-440e-97a9-b0f74486530b", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 5.149675000000001, "benchmark_cost": 0.0} +{"event": "action", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 26, "action": {"name": "search", "arguments": {"query": "\"Storm and Silence\" Robert Thier Wattpad published March 2016 first book published"}, "id": "63dc0cc0-c7bc-48ac-8ecd-81f6e603f57b", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 5.5642700000000005, "benchmark_cost": 0.0} +{"event": "observation", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 26, "observation": {"result": "[\n {\n \"docid\": \"25654\",\n \"score\": 0.7052953243255615,\n \"snippet\": \"---\\ntitle: Storm and Silence (Book Series)\\ndate: 2015-08-12\\n---\\nStorm and Silence is a historical fiction and romance book series written by Robert Thier. As of 2018, there are eight books in the entire series canonically. The series has gained extensive popularity online, with over one hundred million cumulative reads on the online community Wattpad and over six million reads on the Radish Fiction App.\\n\\nThe story follows the adventures of Lilly Linton, a fiery feminist living in 19th-century London, determined to earn her freedom and independence when her life is turned upside down after a chance encounter with the ruthless financial business magnate, Mr Ambrose.\\n\\nBooks\\n\\nMain series\\n\\nCurrently, there are six books in the main series. For each book initially, the chapters are individually uploaded on a weekly basis onto Radish and then Wattpad. When completed, they are edited and published as an individual book both electronically and in paperback. However, the first book is an exception to this as chapters were uploaded to Wattpad only at the time and then the book was self-published since the author did not start using Radish until the second book.\\n\\nThe following is a chronological list of the main books formatted as: Title (year initially uploaded online | year officially published).\\n\\n- Storm and Silence (2014 | 2016)\\n\\n- In the Eye of the Storm (2016 | 2016)\\n\\n- Silence is Golden (2016 | 2017)\\n\\n- Silence Breaking (2017 | 2018)\\n\\n- Hunting for Silence (2018 | TBA)\\n\\n- Storm of Bells (2018 | TBA)\\n\\n- New Storm Rising (2020 | TBA)\\n\\n- Storm over the Caribbean\\n\\n- Silence no More\\n\\nPrequel\\n\\nThe prequel takes place shortly before the beginning of the first book in the main series. It is a stand-alone novella that was commissioned by Radish Fiction and is exclusively on the mobile app.\\n\\n- Before the Storm (2016 | \\u2014)\\n\\nSide story\\n\\nThe following work takes place in between the second and third book of the main series. It was written in thanks from the author to the fanbase for voting for the first book from the main series in the Romance category for the 2016 Goodreads Choice Awards.\\n\\n- Silent Night (2016 | \\u2014\"\n },\n {\n \"docid\": \"90027\",\n \"score\": 0.6753263473510742,\n \"snippet\": \"---\\ntitle: Storm and Silence\\ndate: 2014-04-02\\n---\\n| 'Blast, blast, blast!'\\n\\nThis article is a stub and thus, inadequate. Help Storm and Silence Wiki by expanding it. |\\n\\nStorm and Silence is the first novel in the eponymous series written by Robert Thier. It is free to read on the online community Wattpad and the Radish Fiction Mobile App. The first chapter was uploaded onto Wattpad on April 2014 and the novel was published electronically and in paperback on March 2016. It won the 2015 Award for Story of the Year and currently has a massive fanbase, with over 100 million reads on Wattpad.\\n\\nSynopsis[]\\n\\nWattpad Version[]\\n\\n\\\"It is your choice,\\\" he said, stepping so close to me that our lips were almost touching.\\n\\n\\\"Either do what I say - or get another job.\\\"\\n\\nMy heart stood still as I gazed up into his deep, dark, dangerous eyes...\\n\\nIn a world where women's only role in life is to sit at home and look pretty, Lilly is determined to fight for her freedom. There's only one problem: a powerful man blocking her way. [1]\\n\\nRadish/Published Version[]\\n\\nFreedom \\u2013 that is what Lilly Linton wants most in life. Not marriage, not a brood of squalling brats, and certainly not love, thank you very much!\\n\\nBut freedom is a rare commodity in 19th-century London, where girls are expected to spend their lives sitting at home, fully occupied with looking pretty. Lilly is at her wits' end \\u2013 until a chance encounter with a dark, dangerous and powerful stranger changes her life forever...[2]\\n\\nPlot[]\\n\\nTBA\\n\\nChapters[]\\n\\nWattpad Version[]\\n\\n- Arrested for Good Manners\\n\\n- Ape Bobby\\n\\n- Who He Really Is\\n\\n- Sweet and Solid\\n\\n- Driving Me Wild(ly)\\n\\n- Empire House\\n\\n- His Indecent Demands\\n\\n- Inventing a Sibling and Getting Poked in the Eye\\n\\n- File Fight\\n\\n- The Worst Fate Imaginable\\n\\n- The Dragon's Den\\n\\n- Practicing Impertinence\\n\\n- Ballroom Battle\\n\\n- The Sins of Mr Rikkard Ambrose\\n\\n- It gets mushy-gushy\\n\\n- Unsuitable Suitors\\n\\n- Return to the Game\\n\\n- The Peril of Flowers\\n\\n- The\"\n },\n {\n \"docid\": \"84371\",\n \"score\": 0.5980746746063232,\n \"snippet\": \"---\\ntitle: Author Interview: Rob Thier Talks 'Storm and Silence'\\nauthor: Amber Topping\\ndate: 2024-12-13\\n---\\nToday, we're excited to have author Rob Thier (known to his fans as \\\"Sir Rob\\\") of 'Storm and Silence' and 'The Robber Knight Series' here to discuss his background and latest book!\\n\\nThank you for agreeing to do this interview. Can you tell us a little bit about yourself and how you came to be a writer?\\n\\nI must admit I started writing so early in life that I don't really remember when or how. Maybe when I was nine or ten years old? But it didn't develop into anything serious until I discovered online writing platforms like Wattpad and started studying at an English university a few years ago. Unlike my failed writing attempts in my native language, German, here for the first time I had an opportunity to share my writing with a big audience, ask feedback and gather experience. And my university studies gave me the practice I needed to perfect my grasp on the English language. From there it was just a small step to deciding that I wanted to be a professional writer, and that has been my dream ever since.\\n\\nHow does being a historian affect the way you write stories?\\n\\nIt has made me a little bit of a stickler for accuracy. I do a lot of research for my stories. Even when it is a contemporary story, I want to make sure that the characters behave in a realistic way. Even if I were to write a fantasy story, I would research various mythologies and create a world that is in some way related to real human ideas and conceptions out there in the world. It makes for a very interesting writing process because during the research you always discover things that you never knew before.\\n\\nCongratulations on your latest story, Storm and Silence. For those who aren't familiar, can you explain what it's about?\\n\\nThe story follows the adventures of 19-year-old feminist Lilly and her budding romance with the powerful and ruthless business mogul Rikkard Ambrose. A firm believer in the equality of men and women, Lilly is forced to dress up as a man in order to work for Mr. Ambrose and achieve her independence. The two of them butt heads all the time, but when an important document is stolen from Mr. Ambrose's safe, and he has fought a battle of wits and intrigue with his arch-enemy Lord Dalgliesh, she stands by him through thick and\"\n },\n {\n \"docid\": \"71659\",\n \"score\": 0.5406280755996704,\n \"snippet\": \"---\\ntitle: REVIEW: Storm and Silence by Robert Thier\\nauthor: Nikka\\ndate: 2016-04-03\\n---\\nP A P E R & T R E E S\\nA Book Blog\\n\\nREVIEW: Storm and Silence by Robert Thier\\nA copy of this book was provided by the authorin exchange for an honest review. This does not affect my opinion of the book or the content of my review.\\n\\nSeries: Storm and Silence #1Publication Date: March 19 2016 by Robert Thier\\nGenres: Romance, Historical Fiction\\nNumber of Pages: 596\\nMy Rating: \\u2605\\u2605\\u2605\\u2729\\u2729\\nAmazon // Book Depository // iBooksGoodreads\\n\\nFreedom \\u2013 that is what Lilly Linton wants most in life. Not marriage, not a brood of squalling brats, and certainly not love, thank you very much!\\nBut freedom is a rare commodity in 19th-century London, where girls are expected to spend their lives sitting at home, fully occupied with looking pretty. Lilly is at her wits' end \\u2013 until a chance encounter with a dark, dangerous and powerful stranger changes her life forever\\u2026\\nEnter the world of Mr Rikkard Ambrose, where the only rule is: Knowledge is power is time is money!\\n\\nThe story is set in 19th-century London where women, although dressed beautifully and often treated as fragile flowers, do not have the liberty to pursue their own interests. From education to marriage to rights, women were oppressed and seen as naive and incapable of living on their own.\\nAs Tiffany Stelle retold, \\\"She was expected to keep the house clean, cook the meals, raise the children, decorate the house, and keep her children and husband on the moral high ground. When a husband came home from work he was expecting a smile on his wife, who is dressed perfectly, a house that is spotless and bright, and a forgiving happy environment that would lure him into wanting to return home each day.\\\"\\nBut for Lilly Linton, this is not the way things are supposed to be. She is a suffragette, a feminist, and she needs freedom as much as the next guy needs his daily dose of vanity. So she did what a feminist woman did during the Victorian era\\u2014she pretended to be a man. But unlike any other woman, she was offered a job and she took it, for this could be the key to the freedom she's longing for.\\nThis book\"\n },\n {\n \"docid\": \"33677\",\n \"score\": 0.5101686716079712,\n \"snippet\": \"---\\ntitle: All Books\\ndate: 2025-01-01\\n---\\nInternational Bestselling Master of Suspense\\n\\nAll Books\\n\\nClick any header to sort by that column.\\n\\n| Title | Year | Original | Current | Pen Name |\\n\\n| The Bad Weather Friend | 2024 | Thomas & Mercer | Thomas & Mercer | |\\n\\n| The Forest of Lost Souls | 2024 | Thomas & Mercer | Thomas & Mercer | |\\n\\n| After Death | 2023 | Thomas & Mercer | Thomas & Mercer | |\\n\\n| The House at the End of the World | 2023 | Thomas & Mercer | Thomas & Mercer | |\\n\\n| Quicksilver | 2022 | Amazon Publishing | Amazon Publishing | |\\n\\n| The Big Dark Sky | 2022 | Amazon Publishing | Amazon Publishing | |\\n\\n| Corkscrew | 2021 | Amazon Original Stories | Amazon Original Stories | |\\n\\n| Gentle Is the Angel of Death | 2021 | Amazon Original Stories | Amazon Original Stories | |\\n\\n| Kaleidoscope | 2021 | Amazon Original Stories | Amazon Original Stories | |\\n\\n| Light Has Weight, but Darkness Does Not | 2021 | Amazon Original Stories | Amazon Original Stories | |\\n\\n| Nameless: The Killer Finale | 2021 | Amazon Original Stories | Amazon Original Stories | |\\n\\n| The Lost Soul of the City | 2021 | Amazon Original Stories | Amazon Original Stories | |\\n\\n| The Other Emily | 2021 | Amazon Publishing | Amazon Publishing | |\\n\\n| Zero In | 2021 | Amazon Original Stories | Amazon Original Stories | |\\n\\n| Devoted | 2020 | Amazon Publishing | Amazon Publishing | |\\n\\n| Elsewhere | 2020 | Amazon Publishing | Amazon Publishing | |\\n\\n| In the Heart of the Fire | 2019 | Amazon Original Stories | Amazon Original Stories | |\\n\\n| Memories of Tomorrow | 2019 | Amazon Original Stories | Amazon Original Stories | |\\n\\n| Nameless: The Collection | 2019 | Amazon Publishing | Amazon Publishing | |\\n\\n| Photographing the Dead | 2019 | Amazon Original Stories | Amazon Original Stories | |\\n\\n| Red Rain | 2019 | Amazon Original Stories | Amazon Original Stories | |\\n\\n| The Mercy of Snakes | 2019 | Amazon Original Stories | Amazon Original Stories | |\\n\\n| The Night Window\"\n }\n]", "invoking_actions": [{"name": "search", "arguments": {}, "id": "63dc0cc0-c7bc-48ac-8ecd-81f6e603f57b", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 5.5642700000000005, "benchmark_cost": 0.0} +{"event": "action", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 27, "action": {"name": "submit", "arguments": {"exact_answer": "Storm and Silence", "explanation": "The historical fiction novel described is \"Storm and Silence\" by Robert Thier, published in March 2016. The novel is set in 19th-century London and follows Lilly Linton, a young feminist who disguises herself as a man to work for the ruthless financial business magnate Mr. Rikkard Ambrose. [90027, 25654, 71659]\n\nAccording to the author interview, \"The story follows the adventures of 19-year-old feminist Lilly and her budding romance with the powerful and ruthless business mogul Rikkard Ambrose. A firm believer in the equality of men and women, Lilly is forced to dress up as a man in order to work for Mr. Ambrose and achieve her independence. The two of them butt heads all the time, but when an important document is stolen from Mr. Ambrose's safe, and he has fought a battle of wits and intrigue with his arch-enemy Lord Dalgliesh, she stands by him through thick and thin.\" [84371]\n\nThe chapter list includes titles like \"The Thief,\" \"File Fight,\" \"Isle Marbeau,\" \"Rising Waves,\" \"Shots in the Dark,\" and \"Man and Woman\" at the end of the book, indicating the plot elements involving a stolen document, an island setting, and dramatic sea climax with the rescue. [90027]\n\nThe novel was first uploaded on Wattpad in April 2014 and was officially published as a paperback and ebook in March 2016, meeting the criteria of being published before 2017. [90027, 25654]", "confidence": 85.0}, "id": "47ed5618-3bc2-4552-9656-3befeafa5cf9", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 6.011570000000001, "benchmark_cost": 0.0} +{"event": "observation", "run_id": "2026-01-27T20--21--58.499985", "session_id": "55db42d7_1179_4f71_ac1e_de0fe4fb0ac0", "session_number": 63, "task_key": "1262", "step": 27, "observation": null, "initial": false, "agent_cost": 6.011570000000001, "benchmark_cost": 0.0} diff --git a/labs/AgentStream/exgentic/tests/benchmarks/recordings/swebench/recording.json b/labs/AgentStream/exgentic/tests/benchmarks/recordings/swebench/recording.json new file mode 100644 index 00000000..5f92b49d --- /dev/null +++ b/labs/AgentStream/exgentic/tests/benchmarks/recordings/swebench/recording.json @@ -0,0 +1,5 @@ +{ + "benchmark": "swebench", + "task_id": "django__django-16116", + "expected_score": 1.0 +} diff --git a/labs/AgentStream/exgentic/tests/benchmarks/recordings/swebench/results.json b/labs/AgentStream/exgentic/tests/benchmarks/recordings/swebench/results.json new file mode 100644 index 00000000..1aa68219 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/benchmarks/recordings/swebench/results.json @@ -0,0 +1,119 @@ +{ + "session_id": "1d4aa07c", + "success": true, + "score": 1.0, + "is_finished": true, + "steps": 32, + "agent_cost": 2.2978650000000003, + "benchmark_cost": 0.0, + "execution_time": 494.2505888938904, + "details": { + "score": 1, + "success": true, + "is_finished": true, + "session_metrics": {}, + "session_metadata": {}, + "instance_id": "django__django-16116", + "agent": { + "num_steps": 32, + "commands": [ + "find /testbed -type f -name \"*.py\" | xargs grep -l \"makemigrations\" | head -20", + "cat /testbed/django/core/management/commands/makemigrations.py", + "cat /testbed/django/core/management/commands/makemigrations.py | head -200", + "cat /testbed/django/core/management/commands/makemigrations.py | head -300 | tail -100", + "grep -n \"check_changes\" /testbed/django/core/management/commands/makemigrations.py", + "sed -n '248,260p' /testbed/django/core/management/commands/makemigrations.py", + "grep -n \"check\" /testbed/django/core/management/commands/migrate.py | head -30", + "sed -n '80,90p' /testbed/django/core/management/commands/migrate.py", + "sed -n '254,265p' /testbed/django/core/management/commands/migrate.py", + "grep -n \"check\" /testbed/django/core/management/commands/optimizemigration.py", + "sed -n '25,40p' /testbed/django/core/management/commands/optimizemigration.py", + "sed -n '70,90p' /testbed/django/core/management/commands/optimizemigration.py", + "grep -A 20 \"check\" /testbed/tests/migrations/test_commands.py | head -60", + "grep -n \"check_changes\" /testbed/tests/migrations/test_commands.py", + "grep -n \"makemigrations.*check\" /testbed/tests/migrations/test_commands.py", + "sed -n '2385,2410p' /testbed/tests/migrations/test_commands.py", + "sed -n '238,260p' /testbed/django/core/management/commands/makemigrations.py", + "cat > /tmp/fix.py << 'EOF'\nimport sys\n\n# Read the file\nwith open('/testbed/django/core/management/commands/makemigrations.py', 'r') as f:\n content = f.read()\n\n# Old code\nold_code = \"\"\" else:\n if self.update:\n self.write_to_last_migration_files(changes)\n else:\n self.write_migration_files(changes)\n if check_changes:\n sys.exit(1)\"\"\"\n\n# New code - exit before writing migrations when --check is used\nnew_code = \"\"\" else:\n if check_changes:\n self.log(\"Detected changes.\")\n sys.exit(1)\n if self.update:\n self.write_to_last_migration_files(changes)\n else:\n self.write_migration_files(changes)\"\"\"\n\n# Replace the old code with the new code\nif old_code not in content:\n print(\"Old code not found!\")\n sys.exit(1)\n\ncontent = content.replace(old_code, new_code)\n\n# Write the file back\nwith open('/testbed/django/core/management/commands/makemigrations.py', 'w') as f:\n f.write(content)\n\nprint(\"Fix applied successfully!\")\nEOF\npython /tmp/fix.py", + "sed -n '238,265p' /testbed/django/core/management/commands/makemigrations.py", + "sed -n '68,90p' /testbed/django/core/management/commands/optimizemigration.py", + "grep -n \"log\" /testbed/django/core/management/commands/makemigrations.py | head -30", + "cat > /tmp/fix2.py << 'EOF'\nimport sys\n\n# Read the file\nwith open('/testbed/django/core/management/commands/makemigrations.py', 'r') as f:\n content = f.read()\n\n# Current code (after first fix)\nold_code = \"\"\" else:\n if check_changes:\n self.log(\"Detected changes.\")\n sys.exit(1)\n if self.update:\n self.write_to_last_migration_files(changes)\n else:\n self.write_migration_files(changes)\"\"\"\n\n# New code - just exit without extra message (consistent with migrate --check)\nnew_code = \"\"\" else:\n if check_changes:\n sys.exit(1)\n if self.update:\n self.write_to_last_migration_files(changes)\n else:\n self.write_migration_files(changes)\"\"\"\n\n# Replace the old code with the new code\nif old_code not in content:\n print(\"Old code not found!\")\n sys.exit(1)\n\ncontent = content.replace(old_code, new_code)\n\n# Write the file back\nwith open('/testbed/django/core/management/commands/makemigrations.py', 'w') as f:\n f.write(content)\n\nprint(\"Fix applied successfully!\")\nEOF\npython /tmp/fix2.py", + "sed -n '238,265p' /testbed/django/core/management/commands/makemigrations.py", + "cd /testbed && python -m pytest tests/migrations/test_commands.py::MakeMigrationsTests::test_makemigrations_check -xvs 2>&1 | head -50", + "cd /testbed && python -m django test migrations.test_commands.MakeMigrationsTests.test_makemigrations_check --settings=tests.test_sqlite 2>&1", + "cd /testbed && python tests/runtests.py migrations.test_commands.MakeMigrationsTests.test_makemigrations_check 2>&1", + "cd /testbed && cat > /tmp/test_check.py << 'EOF'\nimport os\nimport sys\nimport tempfile\nimport shutil\n\n# Set up Django settings\nos.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_settings')\n\n# Create a temporary directory for our test app\ntest_dir = tempfile.mkdtemp()\napp_dir = os.path.join(test_dir, 'myapp')\nos.makedirs(app_dir)\n\n# Create app files\nwith open(os.path.join(app_dir, '__init__.py'), 'w') as f:\n f.write('')\n\nwith open(os.path.join(app_dir, 'models.py'), 'w') as f:\n f.write('''\nfrom django.db import models\n\nclass TestModel(models.Model):\n name = models.CharField(max_length=100)\n''')\n\nmigrations_dir = os.path.join(app_dir, 'migrations')\nos.makedirs(migrations_dir)\nwith open(os.path.join(migrations_dir, '__init__.py'), 'w') as f:\n f.write('')\n\n# Create settings file \nwith open(os.path.join(test_dir, 'test_settings.py'), 'w') as f:\n f.write(f'''\nSECRET_KEY = 'test-secret-key'\nINSTALLED_APPS = [\n 'django.contrib.contenttypes',\n 'django.contrib.auth',\n 'myapp',\n]\nDATABASES = {{\n 'default': {{\n 'ENGINE': 'django.db.backends.sqlite3',\n 'NAME': ':memory:',\n }}\n}}\nDEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'\n''')\n\n# Add test_dir to sys.path\nsys.path.insert(0, test_dir)\n\n# Configure Django\nimport django\ndjango.setup()\n\nfrom django.core.management import call_command\nimport io\n\n# Test 1: Check that --check exits with code 1 and does NOT create migrations\nprint(\"Test 1: --check should exit with code 1 without creating migrations\")\nout = io.StringIO()\nerr = io.StringIO()\n\ntry:\n call_command('makemigrations', 'myapp', '--check', stdout=out, stderr=err, verbosity=0)\n print(\"FAIL: Expected SystemExit(1)\")\n sys.exit(1)\nexcept SystemExit as e:\n if e.code != 1:\n print(f\"FAIL: Expected exit code 1, got {e.code}\")\n sys.exit(1)\n\n# Check that no migration file was created\nmigration_files = [f for f in os.listdir(migrations_dir) if f.endswith('.py') and f != '__init__.py']\nif migration_files:\n print(f\"FAIL: Migration files were created: {migration_files}\")\n sys.exit(1)\n\nprint(\"PASS: --check exited with code 1 and no migration files were created\")\n\n# Test 2: Without --check, migrations should be created\nprint(\"\\nTest 2: Without --check, migrations should be created\")\nout = io.StringIO()\ncall_command('makemigrations', 'myapp', stdout=out, stderr=err, verbosity=0)\n\nmigration_files = [f for f in os.listdir(migrations_dir) if f.endswith('.py') and f != '__init__.py']\nif not migration_files:\n print(\"FAIL: Migration files were not created\")\n sys.exit(1)\n\nprint(f\"PASS: Migration files were created: {migration_files}\")\n\n# Test 3: With migrations present and no changes, --check should succeed (exit 0)\nprint(\"\\nTest 3: With no changes, --check should succeed (exit 0)\")\ntry:\n call_command('makemigrations', 'myapp', '--check', stdout=out, stderr=err, verbosity=0)\n print(\"PASS: --check exited with code 0 when no changes\")\nexcept SystemExit as e:\n if e.code != 0:\n print(f\"FAIL: Expected exit code 0, got {e.code}\")\n sys.exit(1)\n print(\"PASS: --check exited with code 0 when no changes\")\n\n# Cleanup\nshutil.rmtree(test_dir)\nprint(\"\\nAll tests passed!\")\nEOF\npython /tmp/test_check.py", + "cd /testbed && python tests/runtests.py migrations.test_commands.MakeMigrationsTests -v 2 2>&1 | tail -50", + "rm /tmp/fix.py /tmp/fix2.py /tmp/test_check.py 2>/dev/null; echo \"Cleaned up\"", + "cd /testbed && git diff", + "cd /testbed && python tests/runtests.py migrations.test_commands -v 1 2>&1 | tail -20" + ], + "edit_commands": [ + "cat > /tmp/fix.py << 'EOF'\nimport sys\n\n# Read the file\nwith open('/testbed/django/core/management/commands/makemigrations.py', 'r') as f:\n content = f.read()\n\n# Old code\nold_code = \"\"\" else:\n if self.update:\n self.write_to_last_migration_files(changes)\n else:\n self.write_migration_files(changes)\n if check_changes:\n sys.exit(1)\"\"\"\n\n# New code - exit before writing migrations when --check is used\nnew_code = \"\"\" else:\n if check_changes:\n self.log(\"Detected changes.\")\n sys.exit(1)\n if self.update:\n self.write_to_last_migration_files(changes)\n else:\n self.write_migration_files(changes)\"\"\"\n\n# Replace the old code with the new code\nif old_code not in content:\n print(\"Old code not found!\")\n sys.exit(1)\n\ncontent = content.replace(old_code, new_code)\n\n# Write the file back\nwith open('/testbed/django/core/management/commands/makemigrations.py', 'w') as f:\n f.write(content)\n\nprint(\"Fix applied successfully!\")\nEOF\npython /tmp/fix.py", + "cat > /tmp/fix2.py << 'EOF'\nimport sys\n\n# Read the file\nwith open('/testbed/django/core/management/commands/makemigrations.py', 'r') as f:\n content = f.read()\n\n# Current code (after first fix)\nold_code = \"\"\" else:\n if check_changes:\n self.log(\"Detected changes.\")\n sys.exit(1)\n if self.update:\n self.write_to_last_migration_files(changes)\n else:\n self.write_migration_files(changes)\"\"\"\n\n# New code - just exit without extra message (consistent with migrate --check)\nnew_code = \"\"\" else:\n if check_changes:\n sys.exit(1)\n if self.update:\n self.write_to_last_migration_files(changes)\n else:\n self.write_migration_files(changes)\"\"\"\n\n# Replace the old code with the new code\nif old_code not in content:\n print(\"Old code not found!\")\n sys.exit(1)\n\ncontent = content.replace(old_code, new_code)\n\n# Write the file back\nwith open('/testbed/django/core/management/commands/makemigrations.py', 'w') as f:\n f.write(content)\n\nprint(\"Fix applied successfully!\")\nEOF\npython /tmp/fix2.py", + "cd /testbed && cat > /tmp/test_check.py << 'EOF'\nimport os\nimport sys\nimport tempfile\nimport shutil\n\n# Set up Django settings\nos.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_settings')\n\n# Create a temporary directory for our test app\ntest_dir = tempfile.mkdtemp()\napp_dir = os.path.join(test_dir, 'myapp')\nos.makedirs(app_dir)\n\n# Create app files\nwith open(os.path.join(app_dir, '__init__.py'), 'w') as f:\n f.write('')\n\nwith open(os.path.join(app_dir, 'models.py'), 'w') as f:\n f.write('''\nfrom django.db import models\n\nclass TestModel(models.Model):\n name = models.CharField(max_length=100)\n''')\n\nmigrations_dir = os.path.join(app_dir, 'migrations')\nos.makedirs(migrations_dir)\nwith open(os.path.join(migrations_dir, '__init__.py'), 'w') as f:\n f.write('')\n\n# Create settings file \nwith open(os.path.join(test_dir, 'test_settings.py'), 'w') as f:\n f.write(f'''\nSECRET_KEY = 'test-secret-key'\nINSTALLED_APPS = [\n 'django.contrib.contenttypes',\n 'django.contrib.auth',\n 'myapp',\n]\nDATABASES = {{\n 'default': {{\n 'ENGINE': 'django.db.backends.sqlite3',\n 'NAME': ':memory:',\n }}\n}}\nDEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'\n''')\n\n# Add test_dir to sys.path\nsys.path.insert(0, test_dir)\n\n# Configure Django\nimport django\ndjango.setup()\n\nfrom django.core.management import call_command\nimport io\n\n# Test 1: Check that --check exits with code 1 and does NOT create migrations\nprint(\"Test 1: --check should exit with code 1 without creating migrations\")\nout = io.StringIO()\nerr = io.StringIO()\n\ntry:\n call_command('makemigrations', 'myapp', '--check', stdout=out, stderr=err, verbosity=0)\n print(\"FAIL: Expected SystemExit(1)\")\n sys.exit(1)\nexcept SystemExit as e:\n if e.code != 1:\n print(f\"FAIL: Expected exit code 1, got {e.code}\")\n sys.exit(1)\n\n# Check that no migration file was created\nmigration_files = [f for f in os.listdir(migrations_dir) if f.endswith('.py') and f != '__init__.py']\nif migration_files:\n print(f\"FAIL: Migration files were created: {migration_files}\")\n sys.exit(1)\n\nprint(\"PASS: --check exited with code 1 and no migration files were created\")\n\n# Test 2: Without --check, migrations should be created\nprint(\"\\nTest 2: Without --check, migrations should be created\")\nout = io.StringIO()\ncall_command('makemigrations', 'myapp', stdout=out, stderr=err, verbosity=0)\n\nmigration_files = [f for f in os.listdir(migrations_dir) if f.endswith('.py') and f != '__init__.py']\nif not migration_files:\n print(\"FAIL: Migration files were not created\")\n sys.exit(1)\n\nprint(f\"PASS: Migration files were created: {migration_files}\")\n\n# Test 3: With migrations present and no changes, --check should succeed (exit 0)\nprint(\"\\nTest 3: With no changes, --check should succeed (exit 0)\")\ntry:\n call_command('makemigrations', 'myapp', '--check', stdout=out, stderr=err, verbosity=0)\n print(\"PASS: --check exited with code 0 when no changes\")\nexcept SystemExit as e:\n if e.code != 0:\n print(f\"FAIL: Expected exit code 0, got {e.code}\")\n sys.exit(1)\n print(\"PASS: --check exited with code 0 when no changes\")\n\n# Cleanup\nshutil.rmtree(test_dir)\nprint(\"\\nAll tests passed!\")\nEOF\npython /tmp/test_check.py", + "rm /tmp/fix.py /tmp/fix2.py /tmp/test_check.py 2>/dev/null; echo \"Cleaned up\"" + ], + "call_submit": true + }, + "patch": { + "generated": true, + "length": 808, + "structurally_valid": true + }, + "container": { + "required": true, + "build_base": null, + "build_env": null, + "build_instance": null, + "started": true, + "patch_exists": true, + "applying_patch": true, + "patch_applied": true, + "removed": true + }, + "evaluation": { + "grading": true, + "resolved": true, + "test_results": { + "FAIL_TO_PASS": { + "expected": 1, + "success": 1, + "failure": 0, + "rate": 100.0, + "display": "expected 1: success 1, failure 0. success rate: 100.0%" + }, + "PASS_TO_PASS": { + "expected": 137, + "success": 137, + "failure": 0, + "rate": 100.0, + "display": "expected 137: success 137, failure 0. success rate: 100.0%" + } + } + }, + "summary": { + "num_steps": 32, + "steps_limit": null, + "num_edit_commands": 4, + "agent_call_submit": 1, + "patch_non_empty": 1, + "container_status": 1, + "fail_to_pass_rate": 100.0, + "pass_to_pass_rate": 100.0, + "score": 1 + } + }, + "cost_reports": { + "agent": { + "model_name": "openai/aws/claude-opus-4-5", + "total_cost": 2.2978650000000003 + }, + "benchmark": { + "model_name": "", + "total_cost": 0 + } + }, + "task_key": "django__django-16116" +} diff --git a/labs/AgentStream/exgentic/tests/benchmarks/recordings/swebench/session.json b/labs/AgentStream/exgentic/tests/benchmarks/recordings/swebench/session.json new file mode 100644 index 00000000..d468a205 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/benchmarks/recordings/swebench/session.json @@ -0,0 +1,48 @@ +{ + "task": "Resolve the given issue by editing the repository files directly on a remote machine.\n\nRepository directory on the remote machine: /testbed\n\n## Issue to resolve:\nmakemigrations --check generating migrations is inconsistent with other uses of --check\nDescription\n\t\nTo script a check for missing migrations but without actually intending to create the migrations, it is necessary to use both --check and --dry-run, which is inconsistent with migrate --check and optimizemigration --check, which just exit (after possibly logging a bit).\nI'm suggesting that makemigrations --check should just exit without making migrations.\nThe choice to write the migrations anyway was not discussed AFAICT on ticket:25604 or ​https://groups.google.com/g/django-developers/c/zczdY6c9KSg/m/ZXCXQsGDDAAJ.\nNoticed when reading ​PR to adjust the documentation of migrate --check. I think the current documentation is silent on this question.\n\n\n## Execution Environment & Access (STRICT):\nAll commands are executed on a remote machine that already contains the full\nrepository and all required system dependencies and prerequisites.\n\nThe remote machine is accessible **ONLY** via the `bash` action.\nAll interactions with the filesystem and environment — including reading files,\nediting files, and running scripts — can be performed by definition only by using `bash`.\nEach command is executed in a separate shell invocation; working directory changes\nand environment variables do not persist between commands. Only files written to disk persist.\nAll bash commands are executed with the working directory set to the root of the remote machine (/).\n\n## Instructions:\nYou must fix the issue by directly modifying files in this repository, in place.\n\n**ALL file edits MUST be performed using `bash` commands on the remote machine.**\nExplanations or code snippets in chat are not sufficient.\n\nOnly changes written to files on disk and present in the working tree will be\nincluded in the final patch. Any fix not reflected in repository files is ignored.\n\nMake changes only to non-test source files in a way that is general and consistent\nwith the existing codebase.\n\n## Hard Boundaries:\n- MODIFY ONLY: regular source code files in /testbed\n- DO NOT MODIFY: tests or configuration files (pyproject.toml, setup.cfg, etc.)\n- DO NOT use interactive editors (vi, nano, etc.)\n- DO NOT suggest changes without implementing them\n\n## Patch & Submission Mechanics:\nSubmission captures the entire working tree using:\n\n `git add -A && git diff --staged C0`\n\nThis means:\n- All intended fixes MUST appear in repository files\n- Any file present at submission time WILL be included in the patch\n- Temporary files, debug artifacts, or helper scripts MUST be removed before submission\n\n## Recommended Workflow:\n1. Analyze the codebase\n2. Reproduce the issue\n3. Edit source files using `bash`\n4. Verify the fix\n5. Test edge cases\n\n## Submission (FINAL STEP):\nWhen finished, use `submit_patch` exactly once with a short summary.\nEnsure the repository contains ONLY the intended final changes.\nAfter submission, no further reading, editing, or testing is allowed.\n\n## Evaluation:\nYour patch will be applied and validated using a hidden test suite.\n\nThe evaluation verifies that:\n1. The reported issue is fully resolved\n2. All previously passing functionality and tests remain unbroken\n\nSuccess requires all tests to pass.\n", + "context": {}, + "actions": [ + { + "name": "bash", + "description": "Run a bash command in the repo root and get the output", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "command": { + "description": "Bash command to execute", + "title": "Command", + "type": "string" + } + }, + "required": [ + "command" + ], + "title": "BashArgs", + "type": "object" + } + }, + { + "name": "finish", + "description": "Finish the task by submitting a brief summary; he system automatically computes the git patch from the repository changes.", + "is_finish": true, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "summary": { + "description": "Brief textual summary of the fix (no diff/patch)", + "title": "Summary", + "type": "string" + } + }, + "required": [ + "summary" + ], + "title": "SubmitPatchArgs", + "type": "object" + } + } + ] +} diff --git a/labs/AgentStream/exgentic/tests/benchmarks/recordings/swebench/trajectory.jsonl b/labs/AgentStream/exgentic/tests/benchmarks/recordings/swebench/trajectory.jsonl new file mode 100644 index 00000000..3d817919 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/benchmarks/recordings/swebench/trajectory.jsonl @@ -0,0 +1,65 @@ +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 0, "observation": {"result": null, "invoking_actions": []}, "initial": true, "agent_cost": 0, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 1, "action": {"name": "bash", "arguments": {"command": "find /testbed -type f -name \"*.py\" | xargs grep -l \"makemigrations\" | head -20"}, "id": "4a8c3335-9eab-4f15-b0a8-305cbce35bed", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.00965, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 1, "observation": {"result": {"output": "/testbed/django/core/management/commands/makemigrations.py\n/testbed/django/core/management/commands/migrate.py\n/testbed/django/db/migrations/loader.py\n/testbed/tests/migrations/test_base.py\n/testbed/tests/migrations/test_commands.py\n/testbed/tests/migrations/test_writer.py\n", "returncode": 0}, "invoking_actions": [{"name": "bash", "arguments": {}, "id": "4a8c3335-9eab-4f15-b0a8-305cbce35bed", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.00965, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 2, "action": {"name": "bash", "arguments": {"command": "cat /testbed/django/core/management/commands/makemigrations.py"}, "id": "6a0ad148-833c-4786-a310-82f750d04c6a", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.020185, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 2, "observation": {"result": {"output": "import os\nimport sys\nimport warnings\nfrom itertools import takewhile\n\nfrom django.apps import apps\nfrom django.conf import settings\nfrom django.core.management.base import BaseCommand, CommandError, no_translations\nfrom django.core.management.utils import run_formatters\nfrom django.db import DEFAULT_DB_ALIAS, OperationalError, connections, router\nfrom django.db.migrations import Migration\nfrom django.db.migrations.autodetector import MigrationAutodetector\nfrom django.db.migrations.loader import MigrationLoader\nfrom django.db.migrations.migration import SwappableTuple\nfrom django.db.migrations.optimizer import MigrationOptimizer\nfrom django.db.migrations.questioner import (\n InteractiveMigrationQuestioner,\n MigrationQuestioner,\n NonInteractiveMigrationQuestioner,\n)\nfrom django.db.migrations.state import ProjectState\nfrom django.db.migrations.utils import get_migration_name_timestamp\nfrom django.db.migrations.writer import MigrationWriter\n\n\nclass Command(BaseCommand):\n help = \"Creates new migration(s) for apps.\"\n\n def add_arguments(self, parser):\n parser.add_argument(\n \"args\",\n metavar=\"app_label\",\n nargs=\"*\",\n help=\"Specify the app label(s) to create migrations for.\",\n )\n parser.add_argument(\n \"--dry-run\",\n action=\"store_true\",\n help=\"Just show what migrations would be made; don't actually write them.\",\n )\n parser.add_argument(\n \"--merge\",\n action=\"store_true\",\n help=\"Enable fixing of migration conflicts.\",\n )\n parser.add_argument(\n \"--empty\",\n action=\"store_true\",\n help=\"Create an empty migration.\",\n )\n parser.add_argument(\n \"--noinput\",\n \"--no-input\",\n action=\"store_false\",\n dest=\"interactive\",\n help=\"Tells Django to NOT prompt the user for input of any kind.\",\n )\n parser.add_argument(\n \"-n\",\n \"--name\",\n help=\"Use this name for migration file(s).\",\n )\n parser.add_argument(\n \"--no-header\",\n action=\"store_false\",\n dest=\"include_header\",\n help=\"Do not add header comments to new migration file(s).\",\n )\n parser.add_argument(\n \"--check\",\n action=\"store_true\",\n dest=\"check_changes\",\n help=\"Exit with a non-zero status if model changes are missing migrations.\",\n )\n parser.add_argument(\n \"--scriptable\",\n action=\"store_true\",\n dest=\"scriptable\",\n help=(\n \"Divert log output and input prompts to stderr, writing only \"\n \"paths of generated migration files to stdout.\"\n ),\n )\n parser.add_argument(\n \"--update\",\n action=\"store_true\",\n dest=\"update\",\n help=(\n \"Merge model changes into the latest migration and optimize the \"\n \"resulting operations.\"\n ),\n )\n\n @property\n def log_output(self):\n return self.stderr if self.scriptable else self.stdout\n\n def log(self, msg):\n self.log_output.write(msg)\n\n @no_translations\n def handle(self, *app_labels, **options):\n self.written_files = []\n self.verbosity = options[\"verbosity\"]\n self.interactive = options[\"interactive\"]\n self.dry_run = options[\"dry_run\"]\n self.merge = options[\"merge\"]\n self.empty = options[\"empty\"]\n self.migration_name = options[\"name\"]\n if self.migration_name and not self.migration_name.isidentifier():\n raise CommandError(\"The migration name must be a valid Python identifier.\")\n self.include_header = options[\"include_header\"]\n check_changes = options[\"check_changes\"]\n self.scriptable = options[\"scriptable\"]\n self.update = options[\"update\"]\n # If logs and prompts are diverted to stderr, remove the ERROR style.\n if self.scriptable:\n self.stderr.style_func = None\n\n # Make sure the app they asked for exists\n app_labels = set(app_labels)\n has_bad_labels = False\n for app_label in app_labels:\n try:\n apps.get_app_config(app_label)\n except LookupError as err:\n self.stderr.write(str(err))\n has_bad_labels = True\n if has_bad_labels:\n sys.exit(2)\n\n # Load the current graph state. Pass in None for the connection so\n # the loader doesn't try to resolve replaced migrations from DB.\n loader = MigrationLoader(None, ignore_no_migrations=True)\n\n # Raise an error if any migrations are applied before their dependencies.\n consistency_check_labels = {config.label for config in apps.get_app_configs()}\n # Non-default databases are only checked if database routers \n\n- OMITTED 12253 chars -\n\ntiveMigrationQuestioner(prompt_output=self.log_output)\n else:\n questioner = MigrationQuestioner(defaults={\"ask_merge\": True})\n\n for app_label, migration_names in conflicts.items():\n # Grab out the migrations in question, and work out their\n # common ancestor.\n merge_migrations = []\n for migration_name in migration_names:\n migration = loader.get_migration(app_label, migration_name)\n migration.ancestry = [\n mig\n for mig in loader.graph.forwards_plan((app_label, migration_name))\n if mig[0] == migration.app_label\n ]\n merge_migrations.append(migration)\n\n def all_items_equal(seq):\n return all(item == seq[0] for item in seq[1:])\n\n merge_migrations_generations = zip(*(m.ancestry for m in merge_migrations))\n common_ancestor_count = sum(\n 1\n for common_ancestor_generation in takewhile(\n all_items_equal, merge_migrations_generations\n )\n )\n if not common_ancestor_count:\n raise ValueError(\n \"Could not find common ancestor of %s\" % migration_names\n )\n # Now work out the operations along each divergent branch\n for migration in merge_migrations:\n migration.branch = migration.ancestry[common_ancestor_count:]\n migrations_ops = (\n loader.get_migration(node_app, node_name).operations\n for node_app, node_name in migration.branch\n )\n migration.merged_operations = sum(migrations_ops, [])\n # In future, this could use some of the Optimizer code\n # (can_optimize_through) to automatically see if they're\n # mergeable. For now, we always just prompt the user.\n if self.verbosity > 0:\n self.log(self.style.MIGRATE_HEADING(\"Merging %s\" % app_label))\n for migration in merge_migrations:\n self.log(self.style.MIGRATE_LABEL(\" Branch %s\" % migration.name))\n for operation in migration.merged_operations:\n self.log(\" - %s\" % operation.describe())\n if questioner.ask_merge(app_label):\n # If they still want to merge it, then write out an empty\n # file depending on the migrations needing merging.\n numbers = [\n MigrationAutodetector.parse_number(migration.name)\n for migration in merge_migrations\n ]\n try:\n biggest_number = max(x for x in numbers if x is not None)\n except ValueError:\n biggest_number = 1\n subclass = type(\n \"Migration\",\n (Migration,),\n {\n \"dependencies\": [\n (app_label, migration.name)\n for migration in merge_migrations\n ],\n },\n )\n parts = [\"%04i\" % (biggest_number + 1)]\n if self.migration_name:\n parts.append(self.migration_name)\n else:\n parts.append(\"merge\")\n leaf_names = \"_\".join(\n sorted(migration.name for migration in merge_migrations)\n )\n if len(leaf_names) > 47:\n parts.append(get_migration_name_timestamp())\n else:\n parts.append(leaf_names)\n migration_name = \"_\".join(parts)\n new_migration = subclass(migration_name, app_label)\n writer = MigrationWriter(new_migration, self.include_header)\n\n if not self.dry_run:\n # Write the merge migrations file to the disk\n with open(writer.path, \"w\", encoding=\"utf-8\") as fh:\n fh.write(writer.as_string())\n run_formatters([writer.path])\n if self.verbosity > 0:\n self.log(\"\\nCreated new merge migration %s\" % writer.path)\n if self.scriptable:\n self.stdout.write(writer.path)\n elif self.verbosity == 3:\n # Alternatively, makemigrations --merge --dry-run --verbosity 3\n # will log the merge migrations rather than saving the file\n # to the disk.\n self.log(\n self.style.MIGRATE_HEADING(\n \"Full merge migrations file '%s':\" % writer.filename\n )\n )\n self.log(writer.as_string())\n", "returncode": 0}, "invoking_actions": [{"name": "bash", "arguments": {}, "id": "6a0ad148-833c-4786-a310-82f750d04c6a", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.020185, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 3, "action": {"name": "bash", "arguments": {"command": "cat /testbed/django/core/management/commands/makemigrations.py | head -200"}, "id": "fa172013-0591-45d5-9e89-02c8ca9f8196", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.046025, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 3, "observation": {"result": {"output": "import os\nimport sys\nimport warnings\nfrom itertools import takewhile\n\nfrom django.apps import apps\nfrom django.conf import settings\nfrom django.core.management.base import BaseCommand, CommandError, no_translations\nfrom django.core.management.utils import run_formatters\nfrom django.db import DEFAULT_DB_ALIAS, OperationalError, connections, router\nfrom django.db.migrations import Migration\nfrom django.db.migrations.autodetector import MigrationAutodetector\nfrom django.db.migrations.loader import MigrationLoader\nfrom django.db.migrations.migration import SwappableTuple\nfrom django.db.migrations.optimizer import MigrationOptimizer\nfrom django.db.migrations.questioner import (\n InteractiveMigrationQuestioner,\n MigrationQuestioner,\n NonInteractiveMigrationQuestioner,\n)\nfrom django.db.migrations.state import ProjectState\nfrom django.db.migrations.utils import get_migration_name_timestamp\nfrom django.db.migrations.writer import MigrationWriter\n\n\nclass Command(BaseCommand):\n help = \"Creates new migration(s) for apps.\"\n\n def add_arguments(self, parser):\n parser.add_argument(\n \"args\",\n metavar=\"app_label\",\n nargs=\"*\",\n help=\"Specify the app label(s) to create migrations for.\",\n )\n parser.add_argument(\n \"--dry-run\",\n action=\"store_true\",\n help=\"Just show what migrations would be made; don't actually write them.\",\n )\n parser.add_argument(\n \"--merge\",\n action=\"store_true\",\n help=\"Enable fixing of migration conflicts.\",\n )\n parser.add_argument(\n \"--empty\",\n action=\"store_true\",\n help=\"Create an empty migration.\",\n )\n parser.add_argument(\n \"--noinput\",\n \"--no-input\",\n action=\"store_false\",\n dest=\"interactive\",\n help=\"Tells Django to NOT prompt the user for input of any kind.\",\n )\n parser.add_argument(\n \"-n\",\n \"--name\",\n help=\"Use this name for migration file(s).\",\n )\n parser.add_argument(\n \"--no-header\",\n action=\"store_false\",\n dest=\"include_header\",\n help=\"Do not add header comments to new migration file(s).\",\n )\n parser.add_argument(\n \"--check\",\n action=\"store_true\",\n dest=\"check_changes\",\n help=\"Exit with a non-zero status if model changes are missing migrations.\",\n )\n parser.add_argument(\n \"--scriptable\",\n action=\"store_true\",\n dest=\"scriptable\",\n help=(\n \"Divert log output and input prompts to stderr, writing only \"\n \"paths of generated migration files to stdout.\"\n ),\n )\n parser.add_argument(\n \"--update\",\n action=\"store_true\",\n dest=\"update\",\n help=(\n \"Merge model changes into the latest migration and optimize the \"\n \"resulting operations.\"\n ),\n )\n\n @property\n def log_output(self):\n return self.stderr if self.scriptable else self.stdout\n\n def log(self, msg):\n self.log_output.write(msg)\n\n @no_translations\n def handle(self, *app_labels, **options):\n self.written_files = []\n self.verbosity = options[\"verbosity\"]\n self.interactive = options[\"interactive\"]\n self.dry_run = options[\"dry_run\"]\n self.merge = options[\"merge\"]\n self.empty = options[\"empty\"]\n self.migration_name = options[\"name\"]\n if self.migration_name and not self.migration_name.isidentifier():\n raise CommandError(\"The migration name must be a valid Python identifier.\")\n self.include_header = options[\"include_header\"]\n check_changes = options[\"check_changes\"]\n self.scriptable = options[\"scriptable\"]\n self.update = options[\"update\"]\n # If logs and prompts are diverted to stderr, remove the ERROR style.\n if self.scriptable:\n self.stderr.style_func = None\n\n # Make sure the app they asked for exists\n app_labels = set(app_labels)\n has_bad_labels = False\n for app_label in app_labels:\n try:\n apps.get_app_config(app_label)\n except LookupError as err:\n self.stderr.write(str(err))\n has_bad_labels = True\n if has_bad_labels:\n sys.exit(2)\n\n # Load the current graph state. Pass in None for the connection so\n # the loader doesn't try to resolve replaced migrations from DB.\n loader = MigrationLoader(None, ignore_no_migrations=True)\n\n # Raise an error if any migrations are applied before their dependencies.\n consistency_check_labels = {config.label for config in apps.get_app_configs()}\n # Non-default databases are only checked if database routers used.\n aliases_to_check = (\n connections if settings.DATABASE_ROUTERS else [DEFAULT_DB_ALIAS]\n )\n for alias in sorted(aliases_to_check):\n connection = connections[alias]\n if connection.settings_dict[\"ENGINE\"] != \"django.db.backends.dummy\" and any(\n # At least one model must be migrated to the database.\n router.allow_migrate(\n connection.alias, app_label, model_name=model._meta.object_name\n )\n for app_label in consistency_check_labels\n for model in apps.get_app_config(app_label).get_models()\n ):\n try:\n loader.check_consistent_history(connection)\n except OperationalError as error:\n warnings.warn(\n \"Got an error checking a consistent migration history \"\n \"performed for database connection '%s': %s\" % (alias, error),\n RuntimeWarning,\n )\n # Before anything else, see if there's conflicting apps and drop out\n # hard if there are any and they don't want to merge\n conflicts = loader.detect_conflicts()\n\n # If app_labels is specified, filter out conflicting migrations for\n # unspecified apps.\n if app_labels:\n conflicts = {\n app_label: conflict\n for app_label, conflict in conflicts.items()\n if app_label in app_labels\n }\n\n if conflicts and not self.merge:\n name_str = \"; \".join(\n \"%s in %s\" % (\", \".join(names), app) for app, names in conflicts.items()\n )\n raise CommandError(\n \"Conflicting migrations detected; multiple leaf nodes in the \"\n \"migration graph: (%s).\\nTo fix them run \"\n \"'python manage.py makemigrations --merge'\" % name_str\n )\n\n # If they want to merge and there's nothing to merge, then politely exit\n if self.merge and not conflicts:\n self.log(\"No conflicts detected to merge.\")\n return\n\n # If they want to merge and there is something to merge, then\n # divert into the merge code\n if self.merge and conflicts:\n return self.handle_merge(loader, conflicts)\n\n if self.interactive:\n questioner = InteractiveMigrationQuestioner(\n specified_apps=app_labels,\n dry_run=self.dry_run,\n prompt_output=self.log_output,\n )\n else:\n questioner = NonInteractiveMigrationQuestioner(\n", "returncode": 0}, "invoking_actions": [{"name": "bash", "arguments": {}, "id": "fa172013-0591-45d5-9e89-02c8ca9f8196", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.046025, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 4, "action": {"name": "bash", "arguments": {"command": "cat /testbed/django/core/management/commands/makemigrations.py | head -300 | tail -100"}, "id": "7fb52e87-e89b-48a1-8033-965f57a3aa38", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.084305, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 4, "observation": {"result": {"output": " specified_apps=app_labels,\n dry_run=self.dry_run,\n verbosity=self.verbosity,\n log=self.log,\n )\n # Set up autodetector\n autodetector = MigrationAutodetector(\n loader.project_state(),\n ProjectState.from_apps(apps),\n questioner,\n )\n\n # If they want to make an empty migration, make one for each app\n if self.empty:\n if not app_labels:\n raise CommandError(\n \"You must supply at least one app label when using --empty.\"\n )\n # Make a fake changes() result we can pass to arrange_for_graph\n changes = {app: [Migration(\"custom\", app)] for app in app_labels}\n changes = autodetector.arrange_for_graph(\n changes=changes,\n graph=loader.graph,\n migration_name=self.migration_name,\n )\n self.write_migration_files(changes)\n return\n\n # Detect changes\n changes = autodetector.changes(\n graph=loader.graph,\n trim_to_apps=app_labels or None,\n convert_apps=app_labels or None,\n migration_name=self.migration_name,\n )\n\n if not changes:\n # No changes? Tell them.\n if self.verbosity >= 1:\n if app_labels:\n if len(app_labels) == 1:\n self.log(\"No changes detected in app '%s'\" % app_labels.pop())\n else:\n self.log(\n \"No changes detected in apps '%s'\"\n % (\"', '\".join(app_labels))\n )\n else:\n self.log(\"No changes detected\")\n else:\n if self.update:\n self.write_to_last_migration_files(changes)\n else:\n self.write_migration_files(changes)\n if check_changes:\n sys.exit(1)\n\n def write_to_last_migration_files(self, changes):\n loader = MigrationLoader(connections[DEFAULT_DB_ALIAS])\n new_changes = {}\n update_previous_migration_paths = {}\n for app_label, app_migrations in changes.items():\n # Find last migration.\n leaf_migration_nodes = loader.graph.leaf_nodes(app=app_label)\n if len(leaf_migration_nodes) == 0:\n raise CommandError(\n f\"App {app_label} has no migration, cannot update last migration.\"\n )\n leaf_migration_node = leaf_migration_nodes[0]\n # Multiple leaf nodes have already been checked earlier in command.\n leaf_migration = loader.graph.nodes[leaf_migration_node]\n # Updated migration cannot be a squash migration, a dependency of\n # another migration, and cannot be already applied.\n if leaf_migration.replaces:\n raise CommandError(\n f\"Cannot update squash migration '{leaf_migration}'.\"\n )\n if leaf_migration_node in loader.applied_migrations:\n raise CommandError(\n f\"Cannot update applied migration '{leaf_migration}'.\"\n )\n depending_migrations = [\n migration\n for migration in loader.disk_migrations.values()\n if leaf_migration_node in migration.dependencies\n ]\n if depending_migrations:\n formatted_migrations = \", \".join(\n [f\"'{migration}'\" for migration in depending_migrations]\n )\n raise CommandError(\n f\"Cannot update migration '{leaf_migration}' that migrations \"\n f\"{formatted_migrations} depend on.\"\n )\n # Build new migration.\n for migration in app_migrations:\n leaf_migration.operations.extend(migration.operations)\n\n for dependency in migration.dependencies:\n if isinstance(dependency, SwappableTuple):\n", "returncode": 0}, "invoking_actions": [{"name": "bash", "arguments": {}, "id": "7fb52e87-e89b-48a1-8033-965f57a3aa38", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.084305, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 5, "action": {"name": "bash", "arguments": {"command": "grep -n \"check_changes\" /testbed/django/core/management/commands/makemigrations.py"}, "id": "20b13125-852f-47a4-a924-03870d726a3f", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.130555, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 5, "observation": {"result": {"output": "72: dest=\"check_changes\",\n113: check_changes = options[\"check_changes\"]\n255: if check_changes:\n", "returncode": 0}, "invoking_actions": [{"name": "bash", "arguments": {}, "id": "20b13125-852f-47a4-a924-03870d726a3f", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.130555, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 6, "action": {"name": "bash", "arguments": {"command": "sed -n '248,260p' /testbed/django/core/management/commands/makemigrations.py"}, "id": "b68f17a3-4b19-44c2-bc66-21ba4d2eec2b", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.17609500000000003, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 6, "observation": {"result": {"output": " else:\n self.log(\"No changes detected\")\n else:\n if self.update:\n self.write_to_last_migration_files(changes)\n else:\n self.write_migration_files(changes)\n if check_changes:\n sys.exit(1)\n\n def write_to_last_migration_files(self, changes):\n loader = MigrationLoader(connections[DEFAULT_DB_ALIAS])\n new_changes = {}\n", "returncode": 0}, "invoking_actions": [{"name": "bash", "arguments": {}, "id": "b68f17a3-4b19-44c2-bc66-21ba4d2eec2b", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.17609500000000003, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 7, "action": {"name": "bash", "arguments": {"command": "grep -n \"check\" /testbed/django/core/management/commands/migrate.py | head -30"}, "id": "fa64ba41-a35c-4d00-aaa7-2a14dfc5ae03", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.225885, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 7, "observation": {"result": {"output": "21: requires_system_checks = []\n25: \"--skip-checks\",\n27: help=\"Skip system checks.\",\n66: \"migration before using this flag. Django will only check for an \"\n81: \"--check\",\n83: dest=\"check_unapplied\",\n99: if not options[\"skip_checks\"]:\n100: self.check(databases=[database])\n120: executor.loader.check_consistent_history(connection)\n257: if options[\"check_unapplied\"]:\n260: if options[\"check_unapplied\"]:\n", "returncode": 0}, "invoking_actions": [{"name": "bash", "arguments": {}, "id": "fa64ba41-a35c-4d00-aaa7-2a14dfc5ae03", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.225885, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 8, "action": {"name": "bash", "arguments": {"command": "sed -n '80,90p' /testbed/django/core/management/commands/migrate.py"}, "id": "94a8160e-b10c-412a-8cd0-e80dd039aab0", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.27476000000000006, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 8, "observation": {"result": {"output": " parser.add_argument(\n \"--check\",\n action=\"store_true\",\n dest=\"check_unapplied\",\n help=(\n \"Exits with a non-zero status if unapplied migrations exist and does \"\n \"not actually apply migrations.\"\n ),\n )\n parser.add_argument(\n \"--prune\",\n", "returncode": 0}, "invoking_actions": [{"name": "bash", "arguments": {}, "id": "94a8160e-b10c-412a-8cd0-e80dd039aab0", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.27476000000000006, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 9, "action": {"name": "bash", "arguments": {"command": "sed -n '254,265p' /testbed/django/core/management/commands/migrate.py"}, "id": "9adb4207-7589-48e2-8c0d-e9251604cf6c", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.324865, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 9, "observation": {"result": {"output": " )\n style = self.style.WARNING if is_error else None\n self.stdout.write(\" \" + message, style)\n if options[\"check_unapplied\"]:\n sys.exit(1)\n return\n if options[\"check_unapplied\"]:\n if plan:\n sys.exit(1)\n return\n if options[\"prune\"]:\n return\n", "returncode": 0}, "invoking_actions": [{"name": "bash", "arguments": {}, "id": "9adb4207-7589-48e2-8c0d-e9251604cf6c", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.324865, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 10, "action": {"name": "bash", "arguments": {"command": "grep -n \"check\" /testbed/django/core/management/commands/optimizemigration.py"}, "id": "faa72cdc-d3e4-49e2-b0e4-3c913bb658cc", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.37767, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 10, "observation": {"result": {"output": "27: \"--check\",\n36: check = options[\"check\"]\n75: if check:\n", "returncode": 0}, "invoking_actions": [{"name": "bash", "arguments": {}, "id": "faa72cdc-d3e4-49e2-b0e4-3c913bb658cc", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.37767, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 11, "action": {"name": "bash", "arguments": {"command": "sed -n '25,40p' /testbed/django/core/management/commands/optimizemigration.py"}, "id": "d54beaea-0cdf-4f6b-a846-b8c559c16a29", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.43021000000000004, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 11, "observation": {"result": {"output": " )\n parser.add_argument(\n \"--check\",\n action=\"store_true\",\n help=\"Exit with a non-zero status if the migration can be optimized.\",\n )\n\n def handle(self, *args, **options):\n verbosity = options[\"verbosity\"]\n app_label = options[\"app_label\"]\n migration_name = options[\"migration_name\"]\n check = options[\"check\"]\n\n # Validate app_label.\n try:\n apps.get_app_config(app_label)\n", "returncode": 0}, "invoking_actions": [{"name": "bash", "arguments": {}, "id": "d54beaea-0cdf-4f6b-a846-b8c559c16a29", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.43021000000000004, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 12, "action": {"name": "bash", "arguments": {"command": "sed -n '70,90p' /testbed/django/core/management/commands/optimizemigration.py"}, "id": "c8bfd35a-2688-423d-a361-298ad9e16f38", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.4842600000000001, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 12, "observation": {"result": {"output": " if verbosity > 0:\n self.stdout.write(\n \"Optimizing from %d operations to %d operations.\"\n % (len(migration.operations), len(new_operations))\n )\n if check:\n sys.exit(1)\n\n # Set the new migration optimizations.\n migration.operations = new_operations\n\n # Write out the optimized migration file.\n writer = MigrationWriter(migration)\n migration_file_string = writer.as_string()\n if writer.needs_manual_porting:\n if migration.replaces:\n raise CommandError(\n \"Migration will require manual porting but is already a squashed \"\n \"migration.\\nTransition to a normal migration first: \"\n \"https://docs.djangoproject.com/en/%s/topics/migrations/\"\n \"#squashing-migrations\" % get_docs_version()\n", "returncode": 0}, "invoking_actions": [{"name": "bash", "arguments": {}, "id": "c8bfd35a-2688-423d-a361-298ad9e16f38", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.4842600000000001, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 13, "action": {"name": "bash", "arguments": {"command": "grep -A 20 \"check\" /testbed/tests/migrations/test_commands.py | head -60"}, "id": "60f237fe-fc3a-4d55-9e79-5b8ee21231df", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.547195, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 13, "observation": {"result": {"output": " def test_migrate_with_system_checks(self):\n out = io.StringIO()\n call_command(\"migrate\", skip_checks=False, no_color=True, stdout=out)\n self.assertIn(\"Apply all migrations: migrated_app\", out.getvalue())\n\n @override_settings(\n INSTALLED_APPS=[\n \"migrations\",\n \"migrations.migrations_test_apps.unmigrated_app_syncdb\",\n ]\n )\n def test_app_without_migrations(self):\n msg = \"App 'unmigrated_app_syncdb' does not have migrations.\"\n with self.assertRaisesMessage(CommandError, msg):\n call_command(\"migrate\", app_label=\"unmigrated_app_syncdb\")\n\n @override_settings(\n MIGRATION_MODULES={\"migrations\": \"migrations.test_migrations_clashing_prefix\"}\n )\n def test_ambiguous_prefix(self):\n msg = (\n \"More than one migration matches 'a' in app 'migrations'. Please \"\n \"be more specific.\"\n--\n that check.\n \"\"\"\n # Make sure no tables are created\n for db in self.databases:\n self.assertTableNotExists(\"migrations_author\", using=db)\n self.assertTableNotExists(\"migrations_tribble\", using=db)\n # Run the migrations to 0001 only\n call_command(\"migrate\", \"migrations\", \"0001\", verbosity=0)\n call_command(\"migrate\", \"migrations\", \"0001\", verbosity=0, database=\"other\")\n # Make sure the right tables exist\n self.assertTableExists(\"migrations_author\")\n self.assertTableNotExists(\"migrations_tribble\")\n # Also check the \"other\" database\n self.assertTableNotExists(\"migrations_author\", using=\"other\")\n self.assertTableExists(\"migrations_tribble\", using=\"other\")\n\n # Fake a roll-back\n call_command(\"migrate\", \"migrations\", \"zero\", fake=True, verbosity=0)\n call_command(\n \"migrate\", \"migrations\", \"zero\", fake=True, verbosity=0, database=\"other\"\n )\n # Make sure the tables still exist\n self.assertTableExists(\"migrations_author\")\n self.assertTableExists(\"migrations_tribble\", using=\"other\")\n # Try to run initial migration\n with self.assertRaises(DatabaseError):\n call_command(\"migrate\", \"migrations\", \"0001\", verbosity=0)\n # Run initial migration with an explicit --fake-initial\n out = io.StringIO()\n with mock.patch(\n \"django.core.management.color.supports_color\", lambda *args: False\n ):\n call_command(\n--\n def test_migrate_check(self):\n with self.assertRaises(SystemExit):\n", "returncode": 0}, "invoking_actions": [{"name": "bash", "arguments": {}, "id": "60f237fe-fc3a-4d55-9e79-5b8ee21231df", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.547195, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 14, "action": {"name": "bash", "arguments": {"command": "grep -n \"check_changes\" /testbed/tests/migrations/test_commands.py"}, "id": "635fbcd7-4ffd-4578-ac44-b24c1901d9a3", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.609625, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 14, "observation": {"result": {"output": "", "returncode": 1}, "invoking_actions": [{"name": "bash", "arguments": {}, "id": "635fbcd7-4ffd-4578-ac44-b24c1901d9a3", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.609625, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 15, "action": {"name": "bash", "arguments": {"command": "grep -n \"makemigrations.*check\" /testbed/tests/migrations/test_commands.py"}, "id": "22459745-242f-4784-a920-cefdd7d81ec6", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.672735, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 15, "observation": {"result": {"output": "1500: def test_makemigrations_consistency_checks_respect_routers(self):\n2389: def test_makemigrations_check(self):\n2391: makemigrations --check should exit with a non-zero status when\n2396: call_command(\"makemigrations\", \"--check\", \"migrations\", verbosity=0)\n2401: call_command(\"makemigrations\", \"--check\", \"migrations\", verbosity=0)\n", "returncode": 0}, "invoking_actions": [{"name": "bash", "arguments": {}, "id": "22459745-242f-4784-a920-cefdd7d81ec6", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.672735, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 16, "action": {"name": "bash", "arguments": {"command": "sed -n '2385,2410p' /testbed/tests/migrations/test_commands.py"}, "id": "b9aedf06-38c6-41aa-889e-c7d345f5d83b", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.737175, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 16, "observation": {"result": {"output": " call_command(\n \"makemigrations\", \"migrations\", \"--name\", \"invalid name\", \"--empty\"\n )\n\n def test_makemigrations_check(self):\n \"\"\"\n makemigrations --check should exit with a non-zero status when\n there are changes to an app requiring migrations.\n \"\"\"\n with self.temporary_migration_module():\n with self.assertRaises(SystemExit):\n call_command(\"makemigrations\", \"--check\", \"migrations\", verbosity=0)\n\n with self.temporary_migration_module(\n module=\"migrations.test_migrations_no_changes\"\n ):\n call_command(\"makemigrations\", \"--check\", \"migrations\", verbosity=0)\n\n def test_makemigrations_migration_path_output(self):\n \"\"\"\n makemigrations should print the relative paths to the migrations unless\n they are outside of the current tree, in which case the absolute path\n should be shown.\n \"\"\"\n out = io.StringIO()\n apps.register_model(\"migrations\", UnicodeModel)\n", "returncode": 0}, "invoking_actions": [{"name": "bash", "arguments": {}, "id": "b9aedf06-38c6-41aa-889e-c7d345f5d83b", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.737175, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 17, "action": {"name": "bash", "arguments": {"command": "sed -n '238,260p' /testbed/django/core/management/commands/makemigrations.py"}, "id": "8b2e1b7d-6696-4efb-8f36-f8808f221e18", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.80659, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 17, "observation": {"result": {"output": " # No changes? Tell them.\n if self.verbosity >= 1:\n if app_labels:\n if len(app_labels) == 1:\n self.log(\"No changes detected in app '%s'\" % app_labels.pop())\n else:\n self.log(\n \"No changes detected in apps '%s'\"\n % (\"', '\".join(app_labels))\n )\n else:\n self.log(\"No changes detected\")\n else:\n if self.update:\n self.write_to_last_migration_files(changes)\n else:\n self.write_migration_files(changes)\n if check_changes:\n sys.exit(1)\n\n def write_to_last_migration_files(self, changes):\n loader = MigrationLoader(connections[DEFAULT_DB_ALIAS])\n new_changes = {}\n", "returncode": 0}, "invoking_actions": [{"name": "bash", "arguments": {}, "id": "8b2e1b7d-6696-4efb-8f36-f8808f221e18", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.80659, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 18, "action": {"name": "bash", "arguments": {"command": "cat > /tmp/fix.py << 'EOF'\nimport sys\n\n# Read the file\nwith open('/testbed/django/core/management/commands/makemigrations.py', 'r') as f:\n content = f.read()\n\n# Old code\nold_code = \"\"\" else:\n if self.update:\n self.write_to_last_migration_files(changes)\n else:\n self.write_migration_files(changes)\n if check_changes:\n sys.exit(1)\"\"\"\n\n# New code - exit before writing migrations when --check is used\nnew_code = \"\"\" else:\n if check_changes:\n self.log(\"Detected changes.\")\n sys.exit(1)\n if self.update:\n self.write_to_last_migration_files(changes)\n else:\n self.write_migration_files(changes)\"\"\"\n\n# Replace the old code with the new code\nif old_code not in content:\n print(\"Old code not found!\")\n sys.exit(1)\n\ncontent = content.replace(old_code, new_code)\n\n# Write the file back\nwith open('/testbed/django/core/management/commands/makemigrations.py', 'w') as f:\n f.write(content)\n\nprint(\"Fix applied successfully!\")\nEOF\npython /tmp/fix.py"}, "id": "1669dbf3-9573-4641-bd39-5ecbf88eb708", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.88393, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 18, "observation": {"result": {"output": "Fix applied successfully!\n", "returncode": 0}, "invoking_actions": [{"name": "bash", "arguments": {}, "id": "1669dbf3-9573-4641-bd39-5ecbf88eb708", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.88393, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 19, "action": {"name": "bash", "arguments": {"command": "sed -n '238,265p' /testbed/django/core/management/commands/makemigrations.py"}, "id": "f2e557d2-5770-45c2-9b6d-ca26bbc55364", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.9555350000000001, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 19, "observation": {"result": {"output": " # No changes? Tell them.\n if self.verbosity >= 1:\n if app_labels:\n if len(app_labels) == 1:\n self.log(\"No changes detected in app '%s'\" % app_labels.pop())\n else:\n self.log(\n \"No changes detected in apps '%s'\"\n % (\"', '\".join(app_labels))\n )\n else:\n self.log(\"No changes detected\")\n else:\n if check_changes:\n self.log(\"Detected changes.\")\n sys.exit(1)\n if self.update:\n self.write_to_last_migration_files(changes)\n else:\n self.write_migration_files(changes)\n\n def write_to_last_migration_files(self, changes):\n loader = MigrationLoader(connections[DEFAULT_DB_ALIAS])\n new_changes = {}\n update_previous_migration_paths = {}\n for app_label, app_migrations in changes.items():\n # Find last migration.\n leaf_migration_nodes = loader.graph.leaf_nodes(app=app_label)\n", "returncode": 0}, "invoking_actions": [{"name": "bash", "arguments": {}, "id": "f2e557d2-5770-45c2-9b6d-ca26bbc55364", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.9555350000000001, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 20, "action": {"name": "bash", "arguments": {"command": "sed -n '68,90p' /testbed/django/core/management/commands/optimizemigration.py"}, "id": "5bc90588-d58b-4d0d-ad4c-73e45a52ce1c", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 1.030645, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 20, "observation": {"result": {"output": " return\n else:\n if verbosity > 0:\n self.stdout.write(\n \"Optimizing from %d operations to %d operations.\"\n % (len(migration.operations), len(new_operations))\n )\n if check:\n sys.exit(1)\n\n # Set the new migration optimizations.\n migration.operations = new_operations\n\n # Write out the optimized migration file.\n writer = MigrationWriter(migration)\n migration_file_string = writer.as_string()\n if writer.needs_manual_porting:\n if migration.replaces:\n raise CommandError(\n \"Migration will require manual porting but is already a squashed \"\n \"migration.\\nTransition to a normal migration first: \"\n \"https://docs.djangoproject.com/en/%s/topics/migrations/\"\n \"#squashing-migrations\" % get_docs_version()\n", "returncode": 0}, "invoking_actions": [{"name": "bash", "arguments": {}, "id": "5bc90588-d58b-4d0d-ad4c-73e45a52ce1c", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 1.030645, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 21, "action": {"name": "bash", "arguments": {"command": "grep -n \"log\" /testbed/django/core/management/commands/makemigrations.py | head -30"}, "id": "b9115f25-c1fc-402e-9246-160fee8bf87f", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 1.1096500000000002, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 21, "observation": {"result": {"output": "80: \"Divert log output and input prompts to stderr, writing only \"\n95: def log_output(self):\n98: def log(self, msg):\n99: self.log_output.write(msg)\n116: # If logs and prompts are diverted to stderr, remove the ERROR style.\n185: self.log(\"No conflicts detected to merge.\")\n197: prompt_output=self.log_output,\n204: log=self.log,\n242: self.log(\"No changes detected in app '%s'\" % app_labels.pop())\n244: self.log(\n249: self.log(\"No changes detected\")\n252: self.log(\"Detected changes.\")\n338: self.log(self.style.MIGRATE_HEADING(\"Migrations for '%s':\" % app_label))\n346: self.log(\" %s\\n\" % self.style.MIGRATE_LABEL(migration_string))\n348: self.log(\" - %s\" % operation.describe())\n370: self.log(\n380: self.log(f\"Deleted {rel_prev_path}\")\n383: # will log the migrations rather than saving the file to\n385: self.log(\n390: self.log(writer.as_string())\n409: questioner = InteractiveMigrationQuestioner(prompt_output=self.log_output)\n452: self.log(self.style.MIGRATE_HEADING(\"Merging %s\" % app_label))\n454: self.log(self.style.MIGRATE_LABEL(\" Branch %s\" % migration.name))\n456: self.log(\" - %s\" % operation.describe())\n500: self.log(\"\\nCreated new merge migration %s\" % writer.path)\n505: # will log the merge migrations rather than saving the file\n507: self.log(\n512: self.log(writer.as_string())\n", "returncode": 0}, "invoking_actions": [{"name": "bash", "arguments": {}, "id": "b9115f25-c1fc-402e-9246-160fee8bf87f", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 1.1096500000000002, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 22, "action": {"name": "bash", "arguments": {"command": "cat > /tmp/fix2.py << 'EOF'\nimport sys\n\n# Read the file\nwith open('/testbed/django/core/management/commands/makemigrations.py', 'r') as f:\n content = f.read()\n\n# Current code (after first fix)\nold_code = \"\"\" else:\n if check_changes:\n self.log(\"Detected changes.\")\n sys.exit(1)\n if self.update:\n self.write_to_last_migration_files(changes)\n else:\n self.write_migration_files(changes)\"\"\"\n\n# New code - just exit without extra message (consistent with migrate --check)\nnew_code = \"\"\" else:\n if check_changes:\n sys.exit(1)\n if self.update:\n self.write_to_last_migration_files(changes)\n else:\n self.write_migration_files(changes)\"\"\"\n\n# Replace the old code with the new code\nif old_code not in content:\n print(\"Old code not found!\")\n sys.exit(1)\n\ncontent = content.replace(old_code, new_code)\n\n# Write the file back\nwith open('/testbed/django/core/management/commands/makemigrations.py', 'w') as f:\n f.write(content)\n\nprint(\"Fix applied successfully!\")\nEOF\npython /tmp/fix2.py"}, "id": "a87fdf01-3e1b-4c2d-9380-0a63d326e8d5", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 1.202385, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 22, "observation": {"result": {"output": "Fix applied successfully!\n", "returncode": 0}, "invoking_actions": [{"name": "bash", "arguments": {}, "id": "a87fdf01-3e1b-4c2d-9380-0a63d326e8d5", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 1.202385, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 23, "action": {"name": "bash", "arguments": {"command": "sed -n '238,265p' /testbed/django/core/management/commands/makemigrations.py"}, "id": "68825302-05fb-4da0-bf03-a8c293933270", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 1.285685, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 23, "observation": {"result": {"output": " # No changes? Tell them.\n if self.verbosity >= 1:\n if app_labels:\n if len(app_labels) == 1:\n self.log(\"No changes detected in app '%s'\" % app_labels.pop())\n else:\n self.log(\n \"No changes detected in apps '%s'\"\n % (\"', '\".join(app_labels))\n )\n else:\n self.log(\"No changes detected\")\n else:\n if check_changes:\n sys.exit(1)\n if self.update:\n self.write_to_last_migration_files(changes)\n else:\n self.write_migration_files(changes)\n\n def write_to_last_migration_files(self, changes):\n loader = MigrationLoader(connections[DEFAULT_DB_ALIAS])\n new_changes = {}\n update_previous_migration_paths = {}\n for app_label, app_migrations in changes.items():\n # Find last migration.\n leaf_migration_nodes = loader.graph.leaf_nodes(app=app_label)\n if len(leaf_migration_nodes) == 0:\n", "returncode": 0}, "invoking_actions": [{"name": "bash", "arguments": {}, "id": "68825302-05fb-4da0-bf03-a8c293933270", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 1.285685, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 24, "action": {"name": "bash", "arguments": {"command": "cd /testbed && python -m pytest tests/migrations/test_commands.py::MakeMigrationsTests::test_makemigrations_check -xvs 2>&1 | head -50"}, "id": "dc419fed-54c8-4d5d-8b12-6b460d3fcdcc", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 1.372265, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 24, "observation": {"result": {"output": "/opt/miniconda3/envs/testbed/bin/python: No module named pytest\n", "returncode": 0}, "invoking_actions": [{"name": "bash", "arguments": {}, "id": "dc419fed-54c8-4d5d-8b12-6b460d3fcdcc", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 1.372265, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 25, "action": {"name": "bash", "arguments": {"command": "cd /testbed && python -m django test migrations.test_commands.MakeMigrationsTests.test_makemigrations_check --settings=tests.test_sqlite 2>&1"}, "id": "73d08cd3-06bd-465b-9bdc-208275a27afa", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 1.459205, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 25, "observation": {"result": {"output": "Found 1 test(s).\nSystem check identified no issues (0 silenced).\nE\n======================================================================\nERROR: migrations (unittest.loader._FailedTest)\n----------------------------------------------------------------------\nImportError: Failed to import test module: migrations\nTraceback (most recent call last):\n File \"/opt/miniconda3/envs/testbed/lib/python3.9/unittest/loader.py\", line 154, in loadTestsFromName\n module = __import__(module_name)\nModuleNotFoundError: No module named 'migrations'\n\n\n----------------------------------------------------------------------\nRan 1 test in 0.003s\n\nFAILED (errors=1)\n", "returncode": 1}, "invoking_actions": [{"name": "bash", "arguments": {}, "id": "73d08cd3-06bd-465b-9bdc-208275a27afa", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 1.459205, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 26, "action": {"name": "bash", "arguments": {"command": "cd /testbed && python tests/runtests.py migrations.test_commands.MakeMigrationsTests.test_makemigrations_check 2>&1"}, "id": "74320f2d-8707-4824-8091-9829dc2c09d1", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 1.54765, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 26, "observation": {"result": {"output": "Creating test database for alias 'default'...\nTesting against Django installed in '/testbed/django' with up to 7 processes\nFound 1 test(s).\nCreating test database for alias 'other'...\nSystem check identified no issues (0 silenced).\n.\n----------------------------------------------------------------------\nRan 1 test in 0.186s\n\nOK\nDestroying test database for alias 'default'...\nDestroying test database for alias 'other'...\n", "returncode": 0}, "invoking_actions": [{"name": "bash", "arguments": {}, "id": "74320f2d-8707-4824-8091-9829dc2c09d1", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 1.54765, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 27, "action": {"name": "bash", "arguments": {"command": "cd /testbed && cat > /tmp/test_check.py << 'EOF'\nimport os\nimport sys\nimport tempfile\nimport shutil\n\n# Set up Django settings\nos.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_settings')\n\n# Create a temporary directory for our test app\ntest_dir = tempfile.mkdtemp()\napp_dir = os.path.join(test_dir, 'myapp')\nos.makedirs(app_dir)\n\n# Create app files\nwith open(os.path.join(app_dir, '__init__.py'), 'w') as f:\n f.write('')\n\nwith open(os.path.join(app_dir, 'models.py'), 'w') as f:\n f.write('''\nfrom django.db import models\n\nclass TestModel(models.Model):\n name = models.CharField(max_length=100)\n''')\n\nmigrations_dir = os.path.join(app_dir, 'migrations')\nos.makedirs(migrations_dir)\nwith open(os.path.join(migrations_dir, '__init__.py'), 'w') as f:\n f.write('')\n\n# Create settings file \nwith open(os.path.join(test_dir, 'test_settings.py'), 'w') as f:\n f.write(f'''\nSECRET_KEY = 'test-secret-key'\nINSTALLED_APPS = [\n 'django.contrib.contenttypes',\n 'django.contrib.auth',\n 'myapp',\n]\nDATABASES = {{\n 'default': {{\n 'ENGINE': 'django.db.backends.sqlite3',\n 'NAME': ':memory:',\n }}\n}}\nDEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'\n''')\n\n# Add test_dir to sys.path\nsys.path.insert(0, test_dir)\n\n# Configure Django\nimport django\ndjango.setup()\n\nfrom django.core.management import call_command\nimport io\n\n# Test 1: Check that --check exits with code 1 and does NOT create migrations\nprint(\"Test 1: --check should exit with code 1 without creating migrations\")\nout = io.StringIO()\nerr = io.StringIO()\n\ntry:\n call_command('makemigrations', 'myapp', '--check', stdout=out, stderr=err, verbosity=0)\n print(\"FAIL: Expected SystemExit(1)\")\n sys.exit(1)\nexcept SystemExit as e:\n if e.code != 1:\n print(f\"FAIL: Expected exit code 1, got {e.code}\")\n sys.exit(1)\n\n# Check that no migration file was created\nmigration_files = [f for f in os.listdir(migrations_dir) if f.endswith('.py') and f != '__init__.py']\nif migration_files:\n print(f\"FAIL: Migration files were created: {migration_files}\")\n sys.exit(1)\n\nprint(\"PASS: --check exited with code 1 and no migration files were created\")\n\n# Test 2: Without --check, migrations should be created\nprint(\"\\nTest 2: Without --check, migrations should be created\")\nout = io.StringIO()\ncall_command('makemigrations', 'myapp', stdout=out, stderr=err, verbosity=0)\n\nmigration_files = [f for f in os.listdir(migrations_dir) if f.endswith('.py') and f != '__init__.py']\nif not migration_files:\n print(\"FAIL: Migration files were not created\")\n sys.exit(1)\n\nprint(f\"PASS: Migration files were created: {migration_files}\")\n\n# Test 3: With migrations present and no changes, --check should succeed (exit 0)\nprint(\"\\nTest 3: With no changes, --check should succeed (exit 0)\")\ntry:\n call_command('makemigrations', 'myapp', '--check', stdout=out, stderr=err, verbosity=0)\n print(\"PASS: --check exited with code 0 when no changes\")\nexcept SystemExit as e:\n if e.code != 0:\n print(f\"FAIL: Expected exit code 0, got {e.code}\")\n sys.exit(1)\n print(\"PASS: --check exited with code 0 when no changes\")\n\n# Cleanup\nshutil.rmtree(test_dir)\nprint(\"\\nAll tests passed!\")\nEOF\npython /tmp/test_check.py"}, "id": "a7cbe6fa-310f-4106-899b-96a613757c9d", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 1.6643600000000003, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 27, "observation": {"result": {"output": "Test 1: --check should exit with code 1 without creating migrations\nPASS: --check exited with code 1 and no migration files were created\n\nTest 2: Without --check, migrations should be created\nPASS: Migration files were created: ['0001_initial.py']\n\nTest 3: With no changes, --check should succeed (exit 0)\nPASS: --check exited with code 0 when no changes\n\nAll tests passed!\n", "returncode": 0}, "invoking_actions": [{"name": "bash", "arguments": {}, "id": "a7cbe6fa-310f-4106-899b-96a613757c9d", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 1.6643600000000003, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 28, "action": {"name": "bash", "arguments": {"command": "cd /testbed && python tests/runtests.py migrations.test_commands.MakeMigrationsTests -v 2 2>&1 | tail -50"}, "id": "8f5e355a-7293-4108-8ec8-1ce5ec94bb49", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 1.7615600000000002, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 28, "observation": {"result": {"output": "test_makemigrations_migrations_modules_path_not_exist (migrations.test_commands.MakeMigrationsTests)\nmakemigrations creates migrations when specifying a custom location ... ok\ntest_makemigrations_model_rename_interactive (migrations.test_commands.MakeMigrationsTests) ... ok\ntest_makemigrations_no_apps_initial (migrations.test_commands.MakeMigrationsTests)\nmakemigrations should detect initial is needed on empty migration ... ok\ntest_makemigrations_no_changes (migrations.test_commands.MakeMigrationsTests)\nmakemigrations exits when there are no changes to an app. ... ok\ntest_makemigrations_no_changes_no_apps (migrations.test_commands.MakeMigrationsTests)\nmakemigrations exits when there are no changes and no apps are specified. ... ok\ntest_makemigrations_no_common_ancestor (migrations.test_commands.MakeMigrationsTests)\nmakemigrations fails to merge migrations with no common ancestor. ... ok\ntest_makemigrations_no_init (migrations.test_commands.MakeMigrationsTests)\nMigration directories without an __init__.py file are allowed. ... ok\ntest_makemigrations_non_interactive_auto_now_add_addition (migrations.test_commands.MakeMigrationsTests)\nNon-interactive makemigrations fails when a default is missing on a ... ok\ntest_makemigrations_non_interactive_no_field_rename (migrations.test_commands.MakeMigrationsTests)\nmakemigrations adds and removes a possible field rename in ... ok\ntest_makemigrations_non_interactive_no_model_rename (migrations.test_commands.MakeMigrationsTests)\nmakemigrations adds and removes a possible model rename in ... ok\ntest_makemigrations_non_interactive_not_null_addition (migrations.test_commands.MakeMigrationsTests)\nNon-interactive makemigrations fails when a default is missing on a ... ok\ntest_makemigrations_non_interactive_not_null_alteration (migrations.test_commands.MakeMigrationsTests)\nNon-interactive makemigrations fails when a default is missing on a ... ok\ntest_makemigrations_non_interactive_unique_callable_default_addition (migrations.test_commands.MakeMigrationsTests) ... ok\ntest_makemigrations_order (migrations.test_commands.MakeMigrationsTests)\nmakemigrations should recognize number-only migrations (0001.py). ... ok\ntest_makemigrations_scriptable (migrations.test_commands.MakeMigrationsTests)\nWith scriptable=True, log output is diverted to stderr, and only the ... ok\ntest_makemigrations_scriptable_merge (migrations.test_commands.MakeMigrationsTests) ... ok\ntest_makemigrations_unspecified_app_with_conflict_merge (migrations.test_commands.MakeMigrationsTests)\nmakemigrations does not create a merge for an unspecified app even if ... ok\ntest_makemigrations_unspecified_app_with_conflict_no_merge (migrations.test_commands.MakeMigrationsTests)\nmakemigrations does not raise a CommandError when an unspecified app ... ok\ntest_makemigrations_update (migrations.test_commands.MakeMigrationsTests) ... ok\ntest_makemigrations_update_applied_migration (migrations.test_commands.MakeMigrationsTests) ... ok\ntest_makemigrations_update_dependency_migration (migrations.test_commands.MakeMigrationsTests) ... ok\ntest_makemigrations_update_existing_name (migrations.test_commands.MakeMigrationsTests) ... ok\ntest_makemigrations_update_manual_porting (migrations.test_commands.MakeMigrationsTests) ... ok\ntest_makemigrations_update_no_migration (migrations.test_commands.MakeMigrationsTests) ... ok\ntest_makemigrations_update_squash_migration (migrations.test_commands.MakeMigrationsTests) ... ok\ntest_makemigrations_with_custom_name (migrations.test_commands.MakeMigrationsTests)\nmakemigrations --name generate a custom migration name. ... ok\ntest_makemigrations_with_invalid_custom_name (migrations.test_commands.MakeMigrationsTests) ... ok\n\n----------------------------------------------------------------------\nRan 61 tests in 58.478s\n\nOK\nDestroying test database for alias 'default' ('file:memorydb_default?mode=memory&cache=shared')...\nDestroying test database for alias 'other' ('file:memorydb_other?mode=memory&cache=shared')...\n", "returncode": 0}, "invoking_actions": [{"name": "bash", "arguments": {}, "id": "8f5e355a-7293-4108-8ec8-1ce5ec94bb49", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 1.7615600000000002, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 29, "action": {"name": "bash", "arguments": {"command": "rm /tmp/fix.py /tmp/fix2.py /tmp/test_check.py 2>/dev/null; echo \"Cleaned up\""}, "id": "e60a8fde-8d0d-4963-93ca-4d23b1c17334", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 1.8653050000000002, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 29, "observation": {"result": {"output": "Cleaned up\n", "returncode": 0}, "invoking_actions": [{"name": "bash", "arguments": {}, "id": "e60a8fde-8d0d-4963-93ca-4d23b1c17334", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 1.8653050000000002, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 30, "action": {"name": "bash", "arguments": {"command": "cd /testbed && git diff"}, "id": "6f4ad6f4-c580-4e05-972a-acc31ca3935b", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 1.9690100000000001, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 30, "observation": {"result": {"output": "diff --git a/django/core/management/commands/makemigrations.py b/django/core/management/commands/makemigrations.py\nindex 2e97d9064b..f257e947ba 100644\n--- a/django/core/management/commands/makemigrations.py\n+++ b/django/core/management/commands/makemigrations.py\n@@ -248,12 +248,12 @@ class Command(BaseCommand):\n else:\n self.log(\"No changes detected\")\n else:\n+ if check_changes:\n+ sys.exit(1)\n if self.update:\n self.write_to_last_migration_files(changes)\n else:\n self.write_migration_files(changes)\n- if check_changes:\n- sys.exit(1)\n \n def write_to_last_migration_files(self, changes):\n loader = MigrationLoader(connections[DEFAULT_DB_ALIAS])\n", "returncode": 0}, "invoking_actions": [{"name": "bash", "arguments": {}, "id": "6f4ad6f4-c580-4e05-972a-acc31ca3935b", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 1.9690100000000001, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 31, "action": {"name": "bash", "arguments": {"command": "cd /testbed && python tests/runtests.py migrations.test_commands -v 1 2>&1 | tail -20"}, "id": "c8ae62f9-a98d-4191-95c3-ee4edcb5869d", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 2.07892, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 31, "observation": {"result": {"output": "Cloning test database for alias 'other'...\nCloning test database for alias 'other'...\nSystem check identified no issues (0 silenced).\n............................................................................................................................................\n----------------------------------------------------------------------\nRan 140 tests in 70.451s\n\nOK\nDestroying test database for alias 'default'...\nDestroying test database for alias 'default'...\nDestroying test database for alias 'default'...\nDestroying test database for alias 'default'...\nDestroying test database for alias 'default'...\nDestroying test database for alias 'default'...\nDestroying test database for alias 'other'...\nDestroying test database for alias 'other'...\nDestroying test database for alias 'other'...\nDestroying test database for alias 'other'...\nDestroying test database for alias 'other'...\nDestroying test database for alias 'other'...\n", "returncode": 0}, "invoking_actions": [{"name": "bash", "arguments": {}, "id": "c8ae62f9-a98d-4191-95c3-ee4edcb5869d", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 2.07892, "benchmark_cost": 0} +{"event": "action", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 32, "action": {"name": "finish", "arguments": {"summary": "Fixed the issue where `makemigrations --check` was generating migrations before exiting. \n\nThe fix changes the behavior so that when `--check` is specified and there are changes, the command exits with code 1 immediately WITHOUT creating migration files. This makes `makemigrations --check` consistent with `migrate --check` and `optimizemigration --check`, which also just exit without making changes.\n\nThe change was minimal - simply moving the `check_changes` exit statement to before the migration file writing logic in `django/core/management/commands/makemigrations.py`."}, "id": "81cb699a-3d6b-4db1-a77b-3a77f576f368", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 2.190265, "benchmark_cost": 0} +{"event": "observation", "run_id": "2026-01-28T07--33--40.579306", "session_id": "c4ab7ec0_ce8c_41f0_a585_cf37ca9b94f0", "session_number": 19, "task_key": "", "step": 32, "observation": null, "initial": false, "agent_cost": 2.190265, "benchmark_cost": 0} diff --git a/labs/AgentStream/exgentic/tests/benchmarks/recordings/tau2/recording.json b/labs/AgentStream/exgentic/tests/benchmarks/recordings/tau2/recording.json new file mode 100644 index 00000000..a5d44355 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/benchmarks/recordings/tau2/recording.json @@ -0,0 +1,5 @@ +{ + "benchmark": "tau2", + "task_id": "47", + "expected_score": 1.0 +} diff --git a/labs/AgentStream/exgentic/tests/benchmarks/recordings/tau2/results.json b/labs/AgentStream/exgentic/tests/benchmarks/recordings/tau2/results.json new file mode 100644 index 00000000..762400b4 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/benchmarks/recordings/tau2/results.json @@ -0,0 +1,77 @@ +{ + "session_id": "9b03dde8", + "success": true, + "score": 1.0, + "is_finished": true, + "steps": 8, + "action_count": 8, + "invalid_action_count": 0, + "agent_cost": 0.34320500000000004, + "benchmark_cost": 0.015026, + "execution_time": 46.77257513999939, + "details": { + "score": 1.0, + "success": true, + "is_finished": true, + "session_metrics": { + "reward": 1.0, + "db_check_db_match": true, + "db_check_db_reward": 1.0 + }, + "session_metadata": { + "reward_info": { + "reward": 1.0, + "db_check": { + "db_match": true, + "db_reward": 1.0 + }, + "env_assertions": [], + "action_checks": [ + { + "action": { + "action_id": "47_0", + "requestor": "assistant", + "name": "get_reservation_details", + "arguments": { + "reservation_id": "H8Q05L" + }, + "info": null, + "compare_args": null + }, + "action_match": true, + "action_reward": 1.0 + } + ], + "nl_assertions": null, + "communicate_checks": null, + "reward_basis": [ + "DB", + "COMMUNICATE" + ], + "reward_breakdown": { + "DB": 1.0, + "COMMUNICATE": 1.0 + }, + "info": { + "env": null, + "nl": null, + "communicate": { + "note": "No communicate_info to evaluate" + }, + "action": null + } + } + } + }, + "cost_reports": { + "agent": { + "model_name": "openai/aws/claude-opus-4-5", + "total_cost": 0.34320500000000004 + }, + "benchmark": { + "model_name": "openai/Azure/gpt-4.1", + "total_cost": 0.015026 + } + }, + "task_key": "47" +} diff --git a/labs/AgentStream/exgentic/tests/benchmarks/recordings/tau2/session.json b/labs/AgentStream/exgentic/tests/benchmarks/recordings/tau2/session.json new file mode 100644 index 00000000..e96ba4e5 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/benchmarks/recordings/tau2/session.json @@ -0,0 +1,642 @@ +{ + "task": "You are a customer service agent that helps the user according to the provided below. Try to be helpful and always follow the policy.", + "context": { + "policy": "# Airline Agent Policy\n\nThe current time is 2024-05-15 15:00:00 EST.\n\nAs an airline agent, you can help users **book**, **modify**, or **cancel** flight reservations. You also handle **refunds and compensation**.\n\nBefore taking any actions that update the booking database (booking, modifying flights, editing baggage, changing cabin class, or updating passenger information), you must list the action details and obtain explicit user confirmation (yes) to proceed.\n\nYou should not provide any information, knowledge, or procedures not provided by the user or available tools, or give subjective recommendations or comments.\n\nYou should only make one tool call at a time, and if you make a tool call, you should not respond to the user simultaneously. If you respond to the user, you should not make a tool call at the same time.\n\nYou should deny user requests that are against this policy.\n\nYou should transfer the user to a human agent if and only if the request cannot be handled within the scope of your actions. To transfer, first make a tool call to transfer_to_human_agents, and then send the message 'YOU ARE BEING TRANSFERRED TO A HUMAN AGENT. PLEASE HOLD ON.' to the user.\n\n## Domain Basic\n\n### User\nEach user has a profile containing:\n- user id\n- email\n- addresses\n- date of birth\n- payment methods\n- membership level\n- reservation numbers\n\nThere are three types of payment methods: **credit card**, **gift card**, **travel certificate**.\n\nThere are three membership levels: **regular**, **silver**, **gold**.\n\n### Flight\nEach flight has the following attributes:\n- flight number\n- origin\n- destination\n- scheduled departure and arrival time (local time)\n\nA flight can be available at multiple dates. For each date:\n- If the status is **available**, the flight has not taken off, available seats and prices are listed.\n- If the status is **delayed** or **on time**, the flight has not taken off, cannot be booked.\n- If the status is **flying**, the flight has taken off but not landed, cannot be booked.\n\nThere are three cabin classes: **basic economy**, **economy**, **business**. **basic economy** is its own class, completely distinct from **economy**.\n\nSeat availability and prices are listed for each cabin class.\n\n### Reservation\nEach reservation specifies the following:\n- reservation id\n- user id\n- trip type\n- flights\n- passengers\n- payment methods\n- created time\n- baggages\n- travel insurance information\n\nThere are two types of trip: **one way** and **round trip**.\n\n## Book flight\n\nThe agent must first obtain the user id from the user. \n\nThe agent should then ask for the trip type, origin, destination.\n\nCabin:\n- Cabin class must be the same across all the flights in a reservation. \n\nPassengers: \n- Each reservation can have at most five passengers. \n- The agent needs to collect the first name, last name, and date of birth for each passenger. \n- All passengers must fly the same flights in the same cabin.\n\nPayment: \n- Each reservation can use at most one travel certificate, at most one credit card, and at most three gift cards. \n- The remaining amount of a travel certificate is not refundable. \n- All payment methods must already be in user profile for safety reasons.\n\nChecked bag allowance: \n- If the booking user is a regular member:\n - 0 free checked bag for each basic economy passenger\n - 1 free checked bag for each economy passenger\n - 2 free checked bags for each business passenger\n- If the booking user is a silver member:\n - 1 free checked bag for each basic economy passenger\n - 2 free checked bag for each economy passenger\n - 3 free checked bags for each business passenger\n- If the booking user is a gold member:\n - 2 free checked bag for each basic economy passenger\n - 3 free checked bag for each economy passenger\n - 4 free checked bags for each business passenger\n- Each extra baggage is 50 dollars.\n\nDo not add checked bags that the user does not need.\n\nTravel insurance: \n- The agent should ask if the user wants to buy the travel insurance.\n- The travel insurance is 30 dollars per passenger and enables full refund if the user needs to cancel the flight given health or weather reasons.\n\n## Modify flight\n\nFirst, the agent must obtain the user id and reservation id. \n- The user must provide their user id. \n- If the user doesn't know their reservation id, the agent should help locate it using available tools.\n\nChange flights: \n- Basic economy flights cannot be modified.\n- Other reservations can be modified without changing the origin, destination, and trip type.\n- Some flight segments can be kept, but their prices will not be updated based on the current price.\n- The API does not check these for the agent, so the agent must make sure the rules apply before calling the API!\n\nChange cabin: \n- Cabin cannot be changed if any flight in the reservation has already been flown.\n- In other cases, all reservations, including basic economy, can change cabin without changing the flights.\n- Cabin class must remain the same across all the flights in the same reservation; changing cabin for just one flight segment is not possible.\n- If the price after cabin change is higher than the original price, the user is required to pay for the difference.\n- If the price after cabin change is lower than the original price, the user is should be refunded the difference.\n\nChange baggage and insurance: \n- The user can add but not remove checked bags.\n- The user cannot add insurance after initial booking.\n\nChange passengers:\n- The user can modify passengers but cannot modify the number of passengers.\n- Even a human agent cannot modify the number of passengers.\n\nPayment: \n- If the flights are changed, the user needs to provide a single gift card or credit card for payment or refund method. The payment method must already be in user profile for safety reasons.\n\n## Cancel flight\n\nFirst, the agent must obtain the user id and reservation id. \n- The user must provide their user id. \n- If the user doesn't know their reservation id, the agent should help locate it using available tools.\n\nThe agent must also obtain the reason for cancellation (change of plan, airline cancelled flight, or other reasons)\n\nIf any portion of the flight has already been flown, the agent cannot help and transfer is needed.\n\nOtherwise, flight can be cancelled if any of the following is true:\n- The booking was made within the last 24 hrs\n- The flight is cancelled by airline\n- It is a business flight\n- The user has travel insurance and the reason for cancellation is covered by insurance.\n\nThe API does not check that cancellation rules are met, so the agent must make sure the rules apply before calling the API!\n\nRefund:\n- The refund will go to original payment methods within 5 to 7 business days.\n\n## Refunds and Compensation\nDo not proactively offer a compensation unless the user explicitly asks for one.\n\nDo not compensate if the user is regular member and has no travel insurance and flies (basic) economy.\n\nAlways confirms the facts before offering compensation.\n\nOnly compensate if the user is a silver/gold member or has travel insurance or flies business.\n\n- If the user complains about cancelled flights in a reservation, the agent can offer a certificate as a gesture after confirming the facts, with the amount being $100 times the number of passengers.\n\n- If the user complains about delayed flights in a reservation and wants to change or cancel the reservation, the agent can offer a certificate as a gesture after confirming the facts and changing or cancelling the reservation, with the amount being $50 times the number of passengers.\n\nDo not offer compensation for any other reason than the ones listed above." + }, + "actions": [ + { + "name": "message", + "description": "Send a message to the user.", + "is_finish": false, + "is_message": true, + "is_hidden": false, + "arguments_schema": { + "properties": { + "content": { + "title": "Content", + "type": "string" + } + }, + "required": [ + "content" + ], + "title": "Message", + "type": "object" + } + }, + { + "name": "book_reservation", + "description": "Book a reservation.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "$defs": { + "FlightInfo": { + "properties": { + "flight_number": { + "description": "Flight number, such as 'HAT001'.", + "title": "Flight Number", + "type": "string" + }, + "date": { + "description": "The date for the flight in the format 'YYYY-MM-DD', such as '2024-05-01'.", + "title": "Date", + "type": "string" + } + }, + "required": [ + "flight_number", + "date" + ], + "title": "FlightInfo", + "type": "object" + }, + "Passenger": { + "properties": { + "first_name": { + "description": "Passenger's first name", + "title": "First Name", + "type": "string" + }, + "last_name": { + "description": "Passenger's last name", + "title": "Last Name", + "type": "string" + }, + "dob": { + "description": "Date of birth in YYYY-MM-DD format", + "title": "Dob", + "type": "string" + } + }, + "required": [ + "first_name", + "last_name", + "dob" + ], + "title": "Passenger", + "type": "object" + }, + "Payment": { + "properties": { + "payment_id": { + "description": "Unique identifier for the payment", + "title": "Payment Id", + "type": "string" + }, + "amount": { + "description": "Payment amount in dollars", + "title": "Amount", + "type": "integer" + } + }, + "required": [ + "payment_id", + "amount" + ], + "title": "Payment", + "type": "object" + } + }, + "properties": { + "user_id": { + "description": "The ID of the user to book the reservation such as 'sara_doe_496'`.", + "title": "User Id", + "type": "string" + }, + "origin": { + "description": "The IATA code for the origin city such as 'SFO'.", + "title": "Origin", + "type": "string" + }, + "destination": { + "description": "The IATA code for the destination city such as 'JFK'.", + "title": "Destination", + "type": "string" + }, + "flight_type": { + "description": "The type of flight such as 'one_way' or 'round_trip'.", + "enum": [ + "round_trip", + "one_way" + ], + "title": "Flight Type", + "type": "string" + }, + "cabin": { + "description": "The cabin class such as 'basic_economy', 'economy', or 'business'.", + "enum": [ + "business", + "economy", + "basic_economy" + ], + "title": "Cabin", + "type": "string" + }, + "flights": { + "description": "An array of objects containing details about each piece of flight.", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/FlightInfo" + }, + { + "additionalProperties": true, + "type": "object" + } + ] + }, + "title": "Flights", + "type": "array" + }, + "passengers": { + "description": "An array of objects containing details about each passenger.", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/Passenger" + }, + { + "additionalProperties": true, + "type": "object" + } + ] + }, + "title": "Passengers", + "type": "array" + }, + "payment_methods": { + "description": "An array of objects containing details about each payment method.", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/Payment" + }, + { + "additionalProperties": true, + "type": "object" + } + ] + }, + "title": "Payment Methods", + "type": "array" + }, + "total_baggages": { + "description": "The total number of baggage items to book the reservation.", + "title": "Total Baggages", + "type": "integer" + }, + "nonfree_baggages": { + "description": "The number of non-free baggage items to book the reservation.", + "title": "Nonfree Baggages", + "type": "integer" + }, + "insurance": { + "description": "Whether the reservation has insurance.", + "enum": [ + "yes", + "no" + ], + "title": "Insurance", + "type": "string" + } + }, + "required": [ + "user_id", + "origin", + "destination", + "flight_type", + "cabin", + "flights", + "passengers", + "payment_methods", + "total_baggages", + "nonfree_baggages", + "insurance" + ], + "title": "parameters", + "type": "object" + } + }, + { + "name": "calculate", + "description": "Calculate the result of a mathematical expression.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "expression": { + "description": "The mathematical expression to calculate, such as '2 + 2'. The expression can contain numbers, operators (+, -, *, /), parentheses, and spaces.", + "title": "Expression", + "type": "string" + } + }, + "required": [ + "expression" + ], + "title": "parameters", + "type": "object" + } + }, + { + "name": "cancel_reservation", + "description": "Cancel the whole reservation.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "reservation_id": { + "description": "The reservation ID, such as 'ZFA04Y'.", + "title": "Reservation Id", + "type": "string" + } + }, + "required": [ + "reservation_id" + ], + "title": "parameters", + "type": "object" + } + }, + { + "name": "get_reservation_details", + "description": "Get the details of a reservation.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "reservation_id": { + "description": "The reservation ID, such as '8JX2WO'.", + "title": "Reservation Id", + "type": "string" + } + }, + "required": [ + "reservation_id" + ], + "title": "parameters", + "type": "object" + } + }, + { + "name": "get_user_details", + "description": "Get the details of a user, including their reservations.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "user_id": { + "description": "The user ID, such as 'sara_doe_496'.", + "title": "User Id", + "type": "string" + } + }, + "required": [ + "user_id" + ], + "title": "parameters", + "type": "object" + } + }, + { + "name": "list_all_airports", + "description": "Returns a list of all available airports.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": {}, + "title": "parameters", + "type": "object" + } + }, + { + "name": "search_direct_flight", + "description": "Search for direct flights between two cities on a specific date.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "origin": { + "description": "The origin city airport in three letters, such as 'JFK'.", + "title": "Origin", + "type": "string" + }, + "destination": { + "description": "The destination city airport in three letters, such as 'LAX'.", + "title": "Destination", + "type": "string" + }, + "date": { + "description": "The date of the flight in the format 'YYYY-MM-DD', such as '2024-01-01'.", + "title": "Date", + "type": "string" + } + }, + "required": [ + "origin", + "destination", + "date" + ], + "title": "parameters", + "type": "object" + } + }, + { + "name": "search_onestop_flight", + "description": "Search for one-stop flights between two cities on a specific date.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "origin": { + "description": "The origin city airport in three letters, such as 'JFK'.", + "title": "Origin", + "type": "string" + }, + "destination": { + "description": "The destination city airport in three letters, such as 'LAX'.", + "title": "Destination", + "type": "string" + }, + "date": { + "description": "The date of the flight in the format 'YYYY-MM-DD', such as '2024-05-01'.", + "title": "Date", + "type": "string" + } + }, + "required": [ + "origin", + "destination", + "date" + ], + "title": "parameters", + "type": "object" + } + }, + { + "name": "send_certificate", + "description": "Send a certificate to a user. Be careful!", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "user_id": { + "description": "The ID of the user to book the reservation, such as 'sara_doe_496'.", + "title": "User Id", + "type": "string" + }, + "amount": { + "description": "The amount of the certificate to send.", + "title": "Amount", + "type": "integer" + } + }, + "required": [ + "user_id", + "amount" + ], + "title": "parameters", + "type": "object" + } + }, + { + "name": "transfer_to_human_agents", + "description": "Transfer the user to a human agent, with a summary of the user's issue.\n\nOnly transfer if\n - the user explicitly asks for a human agent\n - given the policy and the available tools, you cannot solve the user's issue.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "summary": { + "description": "A summary of the user's issue.", + "title": "Summary", + "type": "string" + } + }, + "required": [ + "summary" + ], + "title": "parameters", + "type": "object" + } + }, + { + "name": "update_reservation_baggages", + "description": "Update the baggage information of a reservation.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "reservation_id": { + "description": "The reservation ID, such as 'ZFA04Y'", + "title": "Reservation Id", + "type": "string" + }, + "total_baggages": { + "description": "The updated total number of baggage items included in the reservation.", + "title": "Total Baggages", + "type": "integer" + }, + "nonfree_baggages": { + "description": "The updated number of non-free baggage items included in the reservation.", + "title": "Nonfree Baggages", + "type": "integer" + }, + "payment_id": { + "description": "The payment id stored in user profile, such as 'credit_card_7815826', 'gift_card_7815826', 'certificate_7815826'.", + "title": "Payment Id", + "type": "string" + } + }, + "required": [ + "reservation_id", + "total_baggages", + "nonfree_baggages", + "payment_id" + ], + "title": "parameters", + "type": "object" + } + }, + { + "name": "update_reservation_flights", + "description": "Update the flight information of a reservation.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "$defs": { + "FlightInfo": { + "properties": { + "flight_number": { + "description": "Flight number, such as 'HAT001'.", + "title": "Flight Number", + "type": "string" + }, + "date": { + "description": "The date for the flight in the format 'YYYY-MM-DD', such as '2024-05-01'.", + "title": "Date", + "type": "string" + } + }, + "required": [ + "flight_number", + "date" + ], + "title": "FlightInfo", + "type": "object" + } + }, + "properties": { + "reservation_id": { + "description": "The reservation ID, such as 'ZFA04Y'.", + "title": "Reservation Id", + "type": "string" + }, + "cabin": { + "description": "The cabin class of the reservation", + "enum": [ + "business", + "economy", + "basic_economy" + ], + "title": "Cabin", + "type": "string" + }, + "flights": { + "description": "An array of objects containing details about each piece of flight in the ENTIRE new reservation. Even if the a flight segment is not changed, it should still be included in the array.", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/FlightInfo" + }, + { + "additionalProperties": true, + "type": "object" + } + ] + }, + "title": "Flights", + "type": "array" + }, + "payment_id": { + "description": "The payment id stored in user profile, such as 'credit_card_7815826', 'gift_card_7815826', 'certificate_7815826'.", + "title": "Payment Id", + "type": "string" + } + }, + "required": [ + "reservation_id", + "cabin", + "flights", + "payment_id" + ], + "title": "parameters", + "type": "object" + } + }, + { + "name": "update_reservation_passengers", + "description": "Update the passenger information of a reservation.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "$defs": { + "Passenger": { + "properties": { + "first_name": { + "description": "Passenger's first name", + "title": "First Name", + "type": "string" + }, + "last_name": { + "description": "Passenger's last name", + "title": "Last Name", + "type": "string" + }, + "dob": { + "description": "Date of birth in YYYY-MM-DD format", + "title": "Dob", + "type": "string" + } + }, + "required": [ + "first_name", + "last_name", + "dob" + ], + "title": "Passenger", + "type": "object" + } + }, + "properties": { + "reservation_id": { + "description": "The reservation ID, such as 'ZFA04Y'.", + "title": "Reservation Id", + "type": "string" + }, + "passengers": { + "description": "An array of objects containing details about each passenger.", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/Passenger" + }, + { + "additionalProperties": true, + "type": "object" + } + ] + }, + "title": "Passengers", + "type": "array" + } + }, + "required": [ + "reservation_id", + "passengers" + ], + "title": "parameters", + "type": "object" + } + }, + { + "name": "get_flight_status", + "description": "Get the status of a flight.", + "is_finish": false, + "is_message": false, + "is_hidden": false, + "arguments_schema": { + "properties": { + "flight_number": { + "description": "The flight number.", + "title": "Flight Number", + "type": "string" + }, + "date": { + "description": "The date of the flight.", + "title": "Date", + "type": "string" + } + }, + "required": [ + "flight_number", + "date" + ], + "title": "parameters", + "type": "object" + } + } + ] +} diff --git a/labs/AgentStream/exgentic/tests/benchmarks/recordings/tau2/trajectory.jsonl b/labs/AgentStream/exgentic/tests/benchmarks/recordings/tau2/trajectory.jsonl new file mode 100644 index 00000000..7ed88996 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/benchmarks/recordings/tau2/trajectory.jsonl @@ -0,0 +1,17 @@ +{"event": "observation", "run_id": "2026-01-28T15--14--21.846968", "session_id": "ec1d44d2_c674_4d87_8eaf_70b280dc213c", "session_number": 45, "task_key": "", "step": 0, "observation": {"result": {"sender": "user", "message": "Hi, I need to cancel my flight."}, "invoking_actions": []}, "initial": true, "agent_cost": 0, "benchmark_cost": 0.000936} +{"event": "action", "run_id": "2026-01-28T15--14--21.846968", "session_id": "ec1d44d2_c674_4d87_8eaf_70b280dc213c", "session_number": 45, "task_key": "", "step": 1, "action": {"name": "message", "arguments": {"content": "Hi! I'd be happy to help you cancel your flight. To proceed, I'll need the following information:\n\n1. Your user ID\n2. Your reservation ID (if you don't know it, I can help you find it once you provide your user ID)\n3. The reason for cancellation (e.g., change of plans, airline cancelled the flight, or other reasons)\n\nPlease provide these details so I can assist you."}, "id": "843f6abf-65df-4935-89a1-bca0a93405c2", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.03374, "benchmark_cost": 0.000936} +{"event": "observation", "run_id": "2026-01-28T15--14--21.846968", "session_id": "ec1d44d2_c674_4d87_8eaf_70b280dc213c", "session_number": 45, "task_key": "", "step": 1, "observation": {"result": {"sender": "user", "message": "Sure, my user ID is sophia_silva_7557 and my confirmation number is H8Q05L. I need to cancel because the flight is on the same day as my best friend's birthday."}, "invoking_actions": [{"name": "message", "arguments": {}, "id": "843f6abf-65df-4935-89a1-bca0a93405c2", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.03374, "benchmark_cost": 0.0023480000000000003} +{"event": "action", "run_id": "2026-01-28T15--14--21.846968", "session_id": "ec1d44d2_c674_4d87_8eaf_70b280dc213c", "session_number": 45, "task_key": "", "step": 2, "action": {"name": "get_reservation_details", "arguments": {"reservation_id": "H8Q05L"}, "id": "fba8ffc5-3522-41ac-8c7d-6a453d715a2b", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.06775500000000001, "benchmark_cost": 0.0023480000000000003} +{"event": "observation", "run_id": "2026-01-28T15--14--21.846968", "session_id": "ec1d44d2_c674_4d87_8eaf_70b280dc213c", "session_number": 45, "task_key": "", "step": 2, "observation": {"result": "{\"reservation_id\": \"H8Q05L\", \"user_id\": \"sophia_silva_7557\", \"origin\": \"JFK\", \"destination\": \"ATL\", \"flight_type\": \"one_way\", \"cabin\": \"basic_economy\", \"flights\": [{\"flight_number\": \"HAT268\", \"origin\": \"JFK\", \"destination\": \"ATL\", \"date\": \"2024-05-24\", \"price\": 74}], \"passengers\": [{\"first_name\": \"Harper\", \"last_name\": \"Kovacs\", \"dob\": \"1973-10-26\"}], \"payment_history\": [{\"payment_id\": \"credit_card_4196779\", \"amount\": 104}], \"created_at\": \"2024-05-03T15:12:00\", \"total_baggages\": 0, \"nonfree_baggages\": 0, \"insurance\": \"yes\", \"status\": null}", "invoking_actions": [{"name": "get_reservation_details", "arguments": {}, "id": "fba8ffc5-3522-41ac-8c7d-6a453d715a2b", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.06775500000000001, "benchmark_cost": 0.0023480000000000003} +{"event": "action", "run_id": "2026-01-28T15--14--21.846968", "session_id": "ec1d44d2_c674_4d87_8eaf_70b280dc213c", "session_number": 45, "task_key": "", "step": 3, "action": {"name": "get_user_details", "arguments": {"user_id": "sophia_silva_7557"}, "id": "29f4b212-fa08-4692-9a83-5236cd8d9499", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.06775500000000001, "benchmark_cost": 0.0023480000000000003} +{"event": "observation", "run_id": "2026-01-28T15--14--21.846968", "session_id": "ec1d44d2_c674_4d87_8eaf_70b280dc213c", "session_number": 45, "task_key": "", "step": 3, "observation": {"result": "{\"user_id\": \"sophia_silva_7557\", \"name\": {\"first_name\": \"Sophia\", \"last_name\": \"Silva\"}, \"address\": {\"address1\": \"141 Cedar Avenue\", \"address2\": \"Suite 436\", \"city\": \"Columbus\", \"country\": \"USA\", \"state\": \"OH\", \"zip\": \"43282\"}, \"email\": \"sophia.silva5929@example.com\", \"dob\": \"1957-10-05\", \"payment_methods\": {\"certificate_8045380\": {\"source\": \"certificate\", \"id\": \"certificate_8045380\", \"amount\": 500.0}, \"certificate_3887113\": {\"source\": \"certificate\", \"id\": \"certificate_3887113\", \"amount\": 150.0}, \"credit_card_4196779\": {\"source\": \"credit_card\", \"id\": \"credit_card_4196779\", \"brand\": \"visa\", \"last_four\": \"7238\"}, \"gift_card_5094406\": {\"source\": \"gift_card\", \"id\": \"gift_card_5094406\", \"amount\": 274.0}}, \"saved_passengers\": [{\"first_name\": \"Yara\", \"last_name\": \"Silva\", \"dob\": \"1972-06-16\"}, {\"first_name\": \"Isabella\", \"last_name\": \"Moore\", \"dob\": \"1988-06-04\"}], \"membership\": \"regular\", \"reservations\": [\"NM1VX1\", \"KC18K6\", \"S61CZX\", \"H8Q05L\", \"WUNA5K\"]}", "invoking_actions": [{"name": "get_user_details", "arguments": {}, "id": "29f4b212-fa08-4692-9a83-5236cd8d9499", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.06775500000000001, "benchmark_cost": 0.0023480000000000003} +{"event": "action", "run_id": "2026-01-28T15--14--21.846968", "session_id": "ec1d44d2_c674_4d87_8eaf_70b280dc213c", "session_number": 45, "task_key": "", "step": 4, "action": {"name": "message", "arguments": {"content": "I found your reservation. Here are the details:\n\n**Reservation H8Q05L:**\n- **Flight:** HAT268 from JFK to ATL on May 24, 2024\n- **Cabin:** Basic Economy\n- **Passenger:** Harper Kovacs\n- **Travel Insurance:** Yes\n- **Total Paid:** $104\n\nI understand you need to cancel because the flight conflicts with your best friend's birthday. However, I need to let you know about the cancellation policy:\n\nYour reservation is a **basic economy** flight, and the booking was made on May 3, 2024 (more than 24 hours ago). While you do have travel insurance, the insurance only covers cancellations due to **health or weather reasons**, not personal schedule conflicts like a birthday celebration.\n\nUnfortunately, based on our policy, I cannot cancel this reservation because:\n- It's a basic economy flight\n- The booking was not made within the last 24 hours\n- The flight hasn't been cancelled by the airline\n- The reason for cancellation (attending a birthday) is not covered by your travel insurance\n\nI'm sorry I cannot proceed with this cancellation. Is there anything else I can help you with?"}, "id": "cd6f89e9-14d3-4e04-b5fe-cc90391c20b4", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.11136, "benchmark_cost": 0.0023480000000000003} +{"event": "observation", "run_id": "2026-01-28T15--14--21.846968", "session_id": "ec1d44d2_c674_4d87_8eaf_70b280dc213c", "session_number": 45, "task_key": "", "step": 4, "observation": {"result": {"sender": "user", "message": "I really need to cancel this flight and get a full refund. Is there any way you can make an exception? This is very important to me."}, "invoking_actions": [{"name": "message", "arguments": {}, "id": "cd6f89e9-14d3-4e04-b5fe-cc90391c20b4", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.11136, "benchmark_cost": 0.00424} +{"event": "action", "run_id": "2026-01-28T15--14--21.846968", "session_id": "ec1d44d2_c674_4d87_8eaf_70b280dc213c", "session_number": 45, "task_key": "", "step": 5, "action": {"name": "message", "arguments": {"content": "I completely understand this is important to you, and I wish I could help more. However, I'm not able to make exceptions to the cancellation policy. The rules are in place for all reservations:\n\nFor a basic economy flight to be eligible for cancellation, at least one of these conditions must be met:\n1. The booking was made within the last 24 hours\n2. The flight was cancelled by the airline\n3. The cancellation reason is covered by travel insurance (health or weather-related issues)\n\nSince your situation doesn't meet any of these criteria, I cannot process the cancellation with a refund.\n\nIf you believe there are exceptional circumstances that warrant further review, I can transfer you to a human agent who may have additional options available. Would you like me to do that?\n\nIs there anything else I can assist you with?"}, "id": "837be399-58a0-4f6b-bc1a-f6ae3e577b8e", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.15483, "benchmark_cost": 0.00424} +{"event": "observation", "run_id": "2026-01-28T15--14--21.846968", "session_id": "ec1d44d2_c674_4d87_8eaf_70b280dc213c", "session_number": 45, "task_key": "", "step": 5, "observation": {"result": {"sender": "user", "message": "I really don’t want to be transferred. I just want to get a full refund for this flight. Can you please check again if there’s anything you can do? This is a special situation for me."}, "invoking_actions": [{"name": "message", "arguments": {}, "id": "837be399-58a0-4f6b-bc1a-f6ae3e577b8e", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.15483, "benchmark_cost": 0.006632} +{"event": "action", "run_id": "2026-01-28T15--14--21.846968", "session_id": "ec1d44d2_c674_4d87_8eaf_70b280dc213c", "session_number": 45, "task_key": "", "step": 6, "action": {"name": "message", "arguments": {"content": "I truly understand your frustration, and I appreciate you explaining how important this is to you. However, I must be honest with you - I've already checked all the applicable policies, and I'm not authorized to make exceptions to the cancellation rules.\n\nTo summarize what I've verified:\n- ✗ Basic economy flights are non-refundable for personal reasons\n- ✗ Your booking is outside the 24-hour cancellation window (booked May 3rd)\n- ✗ The airline has not cancelled the flight\n- ✗ Attending a birthday party is not a health or weather reason covered by travel insurance\n\nI am bound by these policies and genuinely cannot process a refund for this cancellation reason. I don't have the ability to override or make exceptions to these rules.\n\nYour options are:\n1. Keep the reservation and take the flight as scheduled\n2. Let me transfer you to a human agent who may have more authority to review special circumstances\n3. Check if there are any other concerns with your booking I can assist with\n\nI'm sorry I couldn't give you the answer you were hoping for. Is there anything else I can help you with today?"}, "id": "a80eed66-3111-4daf-b1a2-b5a643e27acf", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.20183, "benchmark_cost": 0.006632} +{"event": "observation", "run_id": "2026-01-28T15--14--21.846968", "session_id": "ec1d44d2_c674_4d87_8eaf_70b280dc213c", "session_number": 45, "task_key": "", "step": 6, "observation": {"result": {"sender": "user", "message": "I understand the policy, but this is really important to me. I’d appreciate it if you could look into it one more time or escalate it without transferring me. I just want to get a full refund for this flight."}, "invoking_actions": [{"name": "message", "arguments": {}, "id": "a80eed66-3111-4daf-b1a2-b5a643e27acf", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.20183, "benchmark_cost": 0.0096} +{"event": "action", "run_id": "2026-01-28T15--14--21.846968", "session_id": "ec1d44d2_c674_4d87_8eaf_70b280dc213c", "session_number": 45, "task_key": "", "step": 7, "action": {"name": "message", "arguments": {"content": "I hear you, and I genuinely wish I could do more to help. I've looked at your reservation thoroughly, and I want to be completely transparent with you:\n\nThere is no additional review or escalation process I can perform without transferring you to a human agent. As an automated agent, I have a specific set of rules I must follow, and I cannot approve cancellations or refunds that fall outside the policy parameters.\n\nThe system simply does not allow me to:\n- Override cancellation policies\n- Grant exceptions on my own authority\n- Escalate internally without transferring you to a human\n\nIf you don't want to be transferred and you're not willing to keep the flight, unfortunately we're at an impasse that I cannot resolve on my end.\n\nI know this isn't the outcome you wanted, and I'm sorry I couldn't be more helpful in this situation. Is there anything else at all I can assist you with today - perhaps regarding your other reservations or a different matter?"}, "id": "485c43f2-0977-480d-92b2-038d8dc9bae5", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.24967, "benchmark_cost": 0.0096} +{"event": "observation", "run_id": "2026-01-28T15--14--21.846968", "session_id": "ec1d44d2_c674_4d87_8eaf_70b280dc213c", "session_number": 45, "task_key": "", "step": 7, "observation": {"result": {"sender": "user", "message": "I really need you to help me with this. I don’t want to be transferred, and I don’t want to keep the flight if I can’t get a full refund. Please, is there absolutely nothing you can do? This is the fourth time I’m asking."}, "invoking_actions": [{"name": "message", "arguments": {}, "id": "485c43f2-0977-480d-92b2-038d8dc9bae5", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}]}, "initial": false, "agent_cost": 0.24967, "benchmark_cost": 0.011212} +{"event": "action", "run_id": "2026-01-28T15--14--21.846968", "session_id": "ec1d44d2_c674_4d87_8eaf_70b280dc213c", "session_number": 45, "task_key": "", "step": 8, "action": {"name": "message", "arguments": {"content": "I understand this is frustrating, and I appreciate your patience. I want to give you a clear and final answer:\n\n**No, there is absolutely nothing I can do to cancel this reservation with a full refund.** \n\nI have no authority or mechanism to override the cancellation policy. The answer will not change no matter how many times I check. I'm not withholding options from you - I simply do not have any other options available within my capabilities.\n\nYour only two choices at this point are:\n1. **Keep the reservation** and fly on May 24th\n2. **Be transferred to a human agent** who might have discretionary authority that I do not have\n\nI cannot help you further with this specific request. If you change your mind about speaking with a human agent, please let me know. Otherwise, is there a different matter I can assist you with?"}, "id": "3bb9518a-d027-450c-83fc-98c0322099ae", "validation": {"valid": true, "name_valid": true, "args_valid": true, "error": null, "details": {}}}, "initial": false, "agent_cost": 0.29873000000000005, "benchmark_cost": 0.011212} +{"event": "observation", "run_id": "2026-01-28T15--14--21.846968", "session_id": "ec1d44d2_c674_4d87_8eaf_70b280dc213c", "session_number": 45, "task_key": "", "step": 8, "observation": null, "initial": false, "agent_cost": 0.29873000000000005, "benchmark_cost": 0.015026} diff --git a/labs/AgentStream/exgentic/tests/benchmarks/test_benchmark_replay.py b/labs/AgentStream/exgentic/tests/benchmarks/test_benchmark_replay.py new file mode 100644 index 00000000..b9b647c0 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/benchmarks/test_benchmark_replay.py @@ -0,0 +1,153 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Replay recorded sessions to verify the execution loop end-to-end. + +Each subdirectory under ``recordings//`` contains: + trajectory.jsonl — recorded action/observation events + session.json — session manifest (task, context, actions schema) + results.json — recorded score / details + recording.json — metadata: benchmark slug, task_id, expected score + +The tests use ReplayBenchmark + ReplayAgent + ReplaySession so that +**no benchmark third-party dependencies** are required. + +Tests are parametrized across runners (direct, venv, and docker) to verify +that isolation runners work end-to-end with benchmark components. +""" + +from __future__ import annotations + +import json +import platform +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest +from exgentic.agents.replay.replay_agent import ReplayAgent +from exgentic.agents.replay.replay_benchmark import ReplayBenchmark +from exgentic.interfaces.lib.api import evaluate +from exgentic.interfaces.registry import AGENTS, BENCHMARKS, RegistryEntry + +RECORDINGS_DIR = Path(__file__).parent / "recordings" + +# Check runner availability for parametrized tests. +_uv_available = shutil.which("uv") is not None +_docker_available = shutil.which("docker") is not None +if _docker_available: + try: + subprocess.run(["docker", "info"], check=True, capture_output=True, timeout=5) + except Exception: + _docker_available = False + + +@pytest.fixture(autouse=True) +def _register_replay_components(): + """Register replay agent and benchmark for the duration of these tests.""" + AGENTS["replay"] = RegistryEntry( + slug_name="replay", + display_name="Replay Agent", + module="exgentic.agents.replay.replay_agent", + attr="ReplayAgent", + kind="agent", + ) + BENCHMARKS["replay"] = RegistryEntry( + slug_name="replay", + display_name="Replay Benchmark", + module="exgentic.agents.replay.replay_benchmark", + attr="ReplayBenchmark", + kind="benchmark", + ) + yield + AGENTS.pop("replay", None) + BENCHMARKS.pop("replay", None) + + +def _discover_recordings() -> list[tuple[str, Path]]: + """Return (benchmark_slug, recording_dir) pairs.""" + recordings = [] + if not RECORDINGS_DIR.exists(): + return recordings + for bench_dir in sorted(RECORDINGS_DIR.iterdir()): + if not bench_dir.is_dir(): + continue + meta_path = bench_dir / "recording.json" + if not meta_path.exists(): + continue + recordings.append((bench_dir.name, bench_dir)) + return recordings + + +_RECORDINGS = _discover_recordings() + + +@pytest.mark.parametrize( + "runner", + [ + "direct", + pytest.param( + "venv", + marks=[ + pytest.mark.skipif(not _uv_available, reason="uv CLI not available"), + pytest.mark.skipif( + sys.version_info < (3, 12), + reason="Venv replay tests require Python 3.12+ (CPython 3.11 segfault)", + ), + ], + ), + pytest.param( + "docker", + marks=[ + pytest.mark.skipif(not _docker_available, reason="Docker not available"), + pytest.mark.skipif( + sys.version_info < (3, 12), + reason="Docker replay tests require Python 3.12+ (CPython 3.11 segfault)", + ), + ], + ), + ], +) +@pytest.mark.parametrize( + "benchmark_slug,recording_dir", + _RECORDINGS, + ids=[slug for slug, _ in _RECORDINGS], +) +def test_benchmark_replay(benchmark_slug: str, recording_dir: Path, tmp_path: Path, runner: str, request): + """Replay a recorded session using ReplayBenchmark (no real deps needed).""" + # Docker volume mounts on macOS only work under /Users/ (Rancher Desktop + # / Docker Desktop share that by default). pytest's tmp_path lives under + # /var/folders/ which is NOT shared. + if runner == "docker" and platform.system() == "Darwin": + out = Path(tempfile.mkdtemp(prefix=".exgentic_test_", dir=Path.home())) + request.addfinalizer(lambda: shutil.rmtree(out, ignore_errors=True)) + else: + out = tmp_path + + meta = json.loads((recording_dir / "recording.json").read_text()) + task_id = meta["task_id"] + expected_score = meta.get("expected_score") + + agent = ReplayAgent(recording=str(recording_dir)) + benchmark = ReplayBenchmark(recording_dir=str(recording_dir), runner=runner) + + results = evaluate( + benchmark=benchmark, + agent=agent, + task_ids=[task_id], + output_dir=str(out / "outputs"), + ) + + assert results.total_sessions == 1, f"Expected 1 session, got {results.total_sessions}" + session = results.session_results[0] + + # The session should complete without error + assert session.is_finished is not None, f"Session did not finish (status={session.status})" + + # If expected_score is provided, check it + if expected_score is not None: + assert session.score == pytest.approx( + expected_score, abs=0.01 + ), f"Score mismatch: expected {expected_score}, got {session.score}" diff --git a/labs/AgentStream/exgentic/tests/benchmarks/test_tau2_data_dir.py b/labs/AgentStream/exgentic/tests/benchmarks/test_tau2_data_dir.py new file mode 100644 index 00000000..33a90d66 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/benchmarks/test_tau2_data_dir.py @@ -0,0 +1,67 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Tests for tau2 data directory resolution (issue #74).""" + +from __future__ import annotations + +import os +from pathlib import Path +from unittest import mock + +import pytest + + +def test_resolve_tau2_data_dir_prefers_cache(tmp_path: Path) -> None: + """When the cache directory exists, it should be preferred.""" + cache_dir = tmp_path / "benchmarks" / "tau2" + cache_dir.mkdir(parents=True) + + fake_mgr = mock.MagicMock() + fake_mgr.env_path.return_value = cache_dir + + with mock.patch( + "exgentic.environment.instance.get_manager", + return_value=fake_mgr, + ): + from exgentic.benchmarks.tau2 import _resolve_tau2_data_dir + + result = _resolve_tau2_data_dir() + + assert result == str(cache_dir) + + +def test_resolve_tau2_data_dir_falls_back_to_legacy(tmp_path: Path) -> None: + """When cache directory does not exist, fall back to the legacy installation path.""" + non_existent = tmp_path / "benchmarks" / "tau2" + # Do NOT create the directory — env_path points to a path that doesn't exist + + fake_mgr = mock.MagicMock() + fake_mgr.env_path.return_value = non_existent + + with mock.patch( + "exgentic.environment.instance.get_manager", + return_value=fake_mgr, + ): + from exgentic.benchmarks.tau2 import _resolve_tau2_data_dir + + result = _resolve_tau2_data_dir() + + # Should fall back to the legacy path under the package directory + expected_suffix = os.path.join("benchmarks", "tau2", "installation", "tau2-bench", "data") + assert result.endswith(expected_suffix) + + +def test_env_var_takes_precedence(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """If TAU2_DATA_DIR is already set, the module should not overwrite it.""" + custom_dir = str(tmp_path / "my_custom_data") + monkeypatch.setenv("TAU2_DATA_DIR", custom_dir) + + # Re-import to trigger the module-level guard + import importlib + + import exgentic.benchmarks.tau2 as tau2_mod + + importlib.reload(tau2_mod) + + assert os.environ["TAU2_DATA_DIR"] == custom_dir diff --git a/labs/AgentStream/exgentic/tests/core/test_actions.py b/labs/AgentStream/exgentic/tests/core/test_actions.py new file mode 100644 index 00000000..0515409c --- /dev/null +++ b/labs/AgentStream/exgentic/tests/core/test_actions.py @@ -0,0 +1,117 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from exgentic.core.actions import ActionsHandler, build_action, build_unknown_action +from exgentic.core.types import ActionType, SingleAction, SingleObservation +from pydantic import BaseModel + + +class Args(BaseModel): + x: int + + +class MyAction(SingleAction): + arguments: Args + + +def make_action_type(name: str = "do") -> ActionType: + return ActionType(name=name, description="desc", cls=MyAction) + + +def test_build_action_valid(): + action_type = make_action_type() + action = build_action(action_type, {"x": 1}) + + assert action.validation.valid + assert action.arguments.x == 1 + + +def test_build_action_invalid_args_sets_report(): + action_type = make_action_type() + action = build_action(action_type, {"x": "oops"}) + + assert not action.validation.valid + assert not action.validation.args_valid + assert action.validation.error + + +def test_execute_unknown_action_returns_warning_and_stats(): + registry = ActionsHandler() + action = MyAction(name="unknown", arguments=Args(x=1)) + + observation = registry.execute(action) + + assert isinstance(observation, SingleObservation) + assert "Unknown action" in str(observation.result) + assert registry.get_errors_stats().get("unknown_action") == 1 + + +def test_build_unknown_action_sets_validation(): + action = build_unknown_action("unknown_tool", {"foo": "bar"}) + assert not action.validation.valid + assert not action.validation.name_valid + assert action.validation.error == "Unknown action" + + +def test_build_unknown_action_parses_json_string_arguments(): + action = build_unknown_action("unknown_tool", '{"foo": 1}') + assert isinstance(action.arguments, dict) + assert action.arguments["foo"] == 1 + + +def test_execute_validation_error_warns_and_counts(): + registry = ActionsHandler(warn_on_validation_error=True) + action_type = make_action_type() + registry.add_action_type(action_type, handler=lambda a: {"ok": a.arguments.x}) + + action = build_action(action_type, {"x": "bad"}) + observation = registry.execute(action) + + assert isinstance(observation, SingleObservation) + assert "Validation Error in do:" in str(observation.result) + assert registry.get_errors_stats().get("validation_error") == 1 + + +def test_execute_validation_error_custom_handler(): + action_type = make_action_type() + registry = ActionsHandler( + warn_on_validation_error=False, + handle_validation_error=lambda action, msg: SingleObservation( + invoking_actions=[action], result=f"handled:{msg}" + ), + ) + registry.add_action_type(action_type, handler=lambda a: {"ok": a.arguments.x}) + + action = build_action(action_type, {"x": "bad"}) + observation = registry.execute(action) + + assert isinstance(observation, SingleObservation) + assert str(observation.result).startswith("handled:") + assert registry.get_errors_stats().get("validation_error") == 1 + + +def test_handler_exception_wrapped_as_observation(): + registry = ActionsHandler() + action_type = make_action_type("boom") + + def boom_handler(_action: SingleAction): + raise RuntimeError("fail") + + registry.add_action_type(action_type, handler=boom_handler) + action = build_action(action_type, {"x": 1}) + + observation = registry.execute(action) + + assert isinstance(observation, SingleObservation) + assert "Action 'boom' failed" in str(observation.result) + assert registry.get_errors_stats().get("handler_exception") == 1 + + +def test_unknown_message_action_has_friendly_message(): + registry = ActionsHandler() + action = SingleAction.model_construct(name="message", arguments={}) + + observation = registry.execute(action) + + assert isinstance(observation, SingleObservation) + assert "Sending a message is not allowed" in str(observation.result) diff --git a/labs/AgentStream/exgentic/tests/core/test_context_env.py b/labs/AgentStream/exgentic/tests/core/test_context_env.py new file mode 100644 index 00000000..3e34190f --- /dev/null +++ b/labs/AgentStream/exgentic/tests/core/test_context_env.py @@ -0,0 +1,45 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import os + +from exgentic.core import context as context_mod +from exgentic.core.context import ( + Context, + Role, + context_env, + context_env_scope, + set_context, +) + + +def test_context_env_empty_when_no_context(): + token = context_mod._CONTEXT.set(None) + try: + assert context_env() == {} + finally: + context_mod._CONTEXT.reset(token) + + +def test_context_env_scope_applies_and_restores(): + ctx = Context( + run_id="run-1", + output_dir="/tmp/out", + cache_dir="/tmp/cache", + session_id="sess-1", + task_id="task-1", + role=Role.AGENT, + ) + set_context(ctx) + + key = "EXGENTIC_CTX_RUN_ID" + prev = os.environ.get(key) + assert key not in os.environ + + with context_env_scope(): + assert os.environ.get(key) == "run-1" + + assert os.environ.get(key) == prev + os.environ.pop(key, None) diff --git a/labs/AgentStream/exgentic/tests/core/test_run_results_version.py b/labs/AgentStream/exgentic/tests/core/test_run_results_version.py new file mode 100644 index 00000000..6fe821fe --- /dev/null +++ b/labs/AgentStream/exgentic/tests/core/test_run_results_version.py @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from exgentic import __version__ +from exgentic.core.types import RunResults + + +def test_run_results_exgentic_version(): + """RunResults accepts and round-trips the exgentic_version field.""" + results = RunResults( + benchmark_name="test", + agent_name="test", + total_sessions=0, + successful_sessions=0, + session_results=[], + exgentic_version=__version__, + ) + assert results.exgentic_version == __version__ + dumped = results.model_dump() + assert dumped["exgentic_version"] == __version__ diff --git a/labs/AgentStream/exgentic/tests/environment/__init__.py b/labs/AgentStream/exgentic/tests/environment/__init__.py new file mode 100644 index 00000000..367fec41 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/environment/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. diff --git a/labs/AgentStream/exgentic/tests/environment/test_integration.py b/labs/AgentStream/exgentic/tests/environment/test_integration.py new file mode 100644 index 00000000..8749e145 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/environment/test_integration.py @@ -0,0 +1,159 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Integration tests: verify environment state is clean after operations. + +These regression tests ensure that install / uninstall / list leave +the filesystem in the expected state, with no stale artefacts. +""" + +from __future__ import annotations + +import importlib +import json +import sys +from pathlib import Path + +from exgentic.environment import EnvironmentManager, EnvType + +_pkg_counter = 0 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _create_fake_package(tmp_path: Path) -> str: + """Create a minimal importable package and return its dotted module path.""" + global _pkg_counter + _pkg_counter += 1 + tag = f"integ{_pkg_counter}" + + top = f"fpkg_{tag}" + mid = "fbench" + leaf = "mybench" + + pkg_dir = tmp_path / top / mid / leaf + pkg_dir.mkdir(parents=True) + (tmp_path / top / "__init__.py").write_text("") + (tmp_path / top / mid / "__init__.py").write_text("") + (pkg_dir / "__init__.py").write_text("") + (pkg_dir / "main.py").write_text("") + + if str(tmp_path) not in sys.path: + sys.path.insert(0, str(tmp_path)) + importlib.invalidate_caches() + + return f"{top}.{mid}.{leaf}.main" + + +def _get_test_manager(tmp_path: Path) -> EnvironmentManager: + """Return an EnvironmentManager rooted in a tmp_path subdirectory.""" + return EnvironmentManager(base_dir=tmp_path / "envs") + + +# --------------------------------------------------------------------------- +# 1. Install creates marker at correct path +# --------------------------------------------------------------------------- + + +def test_install_benchmark_creates_marker(tmp_path: Path) -> None: + """Install creates .installed at the correct env path.""" + module_path = _create_fake_package(tmp_path) + mgr = _get_test_manager(tmp_path) + + mgr.install("benchmarks/test-bench", env_type=EnvType.LOCAL, module_path=module_path) + + assert mgr.is_installed("benchmarks/test-bench") + marker = mgr.env_path("benchmarks/test-bench") / ".installed" + assert marker.exists() + data = json.loads(marker.read_text()) + assert "local" in data + + +# --------------------------------------------------------------------------- +# 2. Install does NOT create venv dirs in manager space +# --------------------------------------------------------------------------- + + +def test_install_local_does_not_create_venv(tmp_path: Path) -> None: + """LOCAL install should not create a venv/ directory.""" + module_path = _create_fake_package(tmp_path) + mgr = _get_test_manager(tmp_path) + + mgr.install("benchmarks/test-bench", env_type=EnvType.LOCAL, module_path=module_path) + + assert not (mgr.env_path("benchmarks/test-bench") / "venv").exists() + + +# --------------------------------------------------------------------------- +# 3. Uninstall cleans up completely +# --------------------------------------------------------------------------- + + +def test_uninstall_removes_all_traces(tmp_path: Path) -> None: + """After uninstall, no files remain in the env dir.""" + module_path = _create_fake_package(tmp_path) + mgr = _get_test_manager(tmp_path) + + mgr.install("benchmarks/test-bench", env_type=EnvType.LOCAL, module_path=module_path) + mgr.uninstall("benchmarks/test-bench") + + assert not mgr.env_path("benchmarks/test-bench").exists() + + +# --------------------------------------------------------------------------- +# 4. list_installed returns correct format +# --------------------------------------------------------------------------- + + +def test_list_installed_format(tmp_path: Path) -> None: + """list_installed returns dicts with name and environments.""" + module_path = _create_fake_package(tmp_path) + mgr = _get_test_manager(tmp_path) + + mgr.install("benchmarks/alpha", env_type=EnvType.LOCAL, module_path=module_path) + mgr.install("agents/beta", env_type=EnvType.LOCAL, module_path=module_path) + + result = mgr.list_installed() + assert len(result) == 2 + for item in result: + assert "name" in item + assert "environments" in item + assert "local" in item["environments"] + assert "installed_at" in item["environments"]["local"] + + +# --------------------------------------------------------------------------- +# 5. Venv and local can coexist +# --------------------------------------------------------------------------- + + +def test_venv_and_local_coexist(tmp_path: Path) -> None: + """Both env types can be installed for the same name.""" + module_path = _create_fake_package(tmp_path) + mgr = _get_test_manager(tmp_path) + + mgr.install("benchmarks/test-bench", env_type=EnvType.VENV, module_path=module_path) + mgr.install("benchmarks/test-bench", env_type=EnvType.LOCAL, module_path=module_path) + + assert mgr.is_installed("benchmarks/test-bench", env_type=EnvType.VENV) + assert mgr.is_installed("benchmarks/test-bench", env_type=EnvType.LOCAL) + + +# --------------------------------------------------------------------------- +# 6. Runner venv path is under EnvironmentManager space +# --------------------------------------------------------------------------- + + +def test_runner_venv_in_manager_space() -> None: + """VenvRunner venvs should be under ~/.exgentic/{kind}/{slug}/venv/.""" + manager_prefix = str(Path.home() / ".exgentic") + + # Build the path the same way RunnerMixin does. + for kind in ("benchmarks", "agents"): + venv_dir = str(Path.home() / ".exgentic" / kind / "test-slug" / "venv") + assert venv_dir.startswith(manager_prefix), "venv_dir should be under ~/.exgentic/" + assert kind in venv_dir, f"venv_dir should contain {kind}" + assert venv_dir.endswith("/venv"), "venv_dir should end with /venv" diff --git a/labs/AgentStream/exgentic/tests/environment/test_manager.py b/labs/AgentStream/exgentic/tests/environment/test_manager.py new file mode 100644 index 00000000..14c2ea1f --- /dev/null +++ b/labs/AgentStream/exgentic/tests/environment/test_manager.py @@ -0,0 +1,1967 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Tests for exgentic.environment.manager. + +Tests are organized by the assumptions the rest of the repo makes about +the manager's capabilities. Every public method and every env type +is tested in isolation. +""" + +from __future__ import annotations + +import importlib +import json +import os +import shutil +import stat +import subprocess +import sys +import textwrap +from pathlib import Path +from unittest import mock + +import pytest +from exgentic.environment import EnvironmentManager, EnvType +from exgentic.environment.helpers import build_subprocess_env, find_package_file, require_uv + +_pkg_counter = 0 + +_real_subprocess_run = subprocess.run + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _create_fake_package( + tmp_path: Path, + *, + with_requirements: bool = True, + with_setup: bool = True, + with_system_deps: bool = False, +) -> str: + """Create a minimal importable package with optional resource files.""" + global _pkg_counter + _pkg_counter += 1 + tag = f"p{_pkg_counter}" + + top = f"fpkg_{tag}" + mid = "fbench" + leaf = "mybench" + + pkg_dir = tmp_path / top / mid / leaf + pkg_dir.mkdir(parents=True) + (tmp_path / top / "__init__.py").write_text("") + (tmp_path / top / mid / "__init__.py").write_text("") + (pkg_dir / "__init__.py").write_text("") + (pkg_dir / "main.py").write_text("") + + if with_requirements: + (pkg_dir / "requirements.txt").write_text("requests\n") + + if with_setup: + script = textwrap.dedent( + """\ + #!/usr/bin/env bash + mkdir -p "data" + touch "data/setup_ran.txt" + """ + ) + setup_sh = pkg_dir / "setup.sh" + setup_sh.write_text(script) + setup_sh.chmod(setup_sh.stat().st_mode | stat.S_IEXEC) + + if with_system_deps: + (pkg_dir / "system-deps.txt").write_text("curl\nwget\n") + + if str(tmp_path) not in sys.path: + sys.path.insert(0, str(tmp_path)) + importlib.invalidate_caches() + + return f"{top}.{mid}.{leaf}.main" + + +def _docker_mock_result(**overrides): + result = mock.MagicMock() + result.returncode = overrides.get("returncode", 0) + result.stdout = overrides.get("stdout", "") + result.stderr = overrides.get("stderr", "") + return result + + +def _create_fake_project(tmp_path: Path, *, name: str = "myproject") -> Path: + """Create a minimal Python project with pyproject.toml and src layout.""" + project = tmp_path / f"project_{name}" + project.mkdir(parents=True) + pkg_name = name.replace("-", "_") + src_dir = project / "src" / pkg_name + src_dir.mkdir(parents=True) + (src_dir / "__init__.py").write_text('__version__ = "0.1.0"\n') + (project / "README.md").write_text(f"# {name}\n") + (project / "pyproject.toml").write_text( + textwrap.dedent( + f"""\ + [project] + name = "{name}" + version = "0.1.0" + requires-python = ">=3.10" + dependencies = [] + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + """ + ) + ) + return project + + +# --------------------------------------------------------------------------- +# Venv install +# --------------------------------------------------------------------------- + + +class TestVenvInstall: + """Venv is the default env type. + + The evaluate flow and venv runner depend on: venv/ dir existing, + .installed marker with installed_at, and setup.sh having been run. + """ + + def test_creates_venv_and_marker(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + env_dir = mgr.install("mybench", module_path=module_path) + + assert env_dir.is_dir() + assert (env_dir / "venv").is_dir() + assert (env_dir / "venv" / "bin" / "python").exists() + marker = json.loads((env_dir / ".installed").read_text()) + assert "venv" in marker + assert "installed_at" in marker["venv"] + + def test_skips_if_already_installed(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + mgr.install("mybench", module_path=module_path) + mtime = (mgr.env_path("mybench") / ".installed").stat().st_mtime + + mgr.install("mybench", module_path=module_path) + assert (mgr.env_path("mybench") / ".installed").stat().st_mtime == mtime + + def test_force_reinstalls(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + mgr.install("mybench", module_path=module_path) + sentinel = mgr.env_path("mybench") / "venv" / "sentinel.txt" + sentinel.write_text("old") + + mgr.install("mybench", force=True, module_path=module_path) + + assert not sentinel.exists() + assert mgr.is_installed("mybench", env_type=EnvType.VENV) + + def test_runs_setup_sh(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=True) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + env_dir = mgr.install("mybench", module_path=module_path) + + assert (env_dir / "data" / "setup_ran.txt").is_file() + + def test_cleanup_on_failure(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + def fail_pip(cmd, **kwargs): + if isinstance(cmd, list) and "pip" in cmd and "install" in cmd: + raise subprocess.CalledProcessError(1, cmd) + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=fail_pip): + with pytest.raises(subprocess.CalledProcessError): + mgr.install("mybench", packages=["some-pkg"], module_path=module_path) + + venv_dir = mgr.env_path("mybench") / "venv" + assert not venv_dir.exists() + assert not mgr.is_installed("mybench", env_type=EnvType.VENV) + + def test_venv_python_path(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + mgr.install("mybench", module_path=module_path) + + python_path = mgr.venv_python("mybench") + assert python_path == str(tmp_path / "envs" / "mybench" / "venv" / "bin" / "python") + assert Path(python_path).exists() + + +# --------------------------------------------------------------------------- +# Local install +# --------------------------------------------------------------------------- + + +class TestLocalInstall: + """Local install uses the current Python (sys.executable). + + Used for debugging/development. The manager must record which + Python was used so runners can find it. + """ + + def test_installs_without_venv(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + env_dir = mgr.install("mybench", env_type=EnvType.LOCAL, module_path=module_path) + + assert env_dir.is_dir() + assert not (env_dir / "venv").exists() + assert mgr.is_installed("mybench", env_type=EnvType.LOCAL) + + def test_marker_has_python_path(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + mgr.install("mybench", env_type=EnvType.LOCAL, module_path=module_path) + + marker = json.loads((mgr.env_path("mybench") / ".installed").read_text()) + assert marker["local"]["python"] == sys.executable + assert "installed_at" in marker["local"] + + def test_skips_if_already_installed(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + mgr.install("mybench", env_type=EnvType.LOCAL, module_path=module_path) + mtime = (mgr.env_path("mybench") / ".installed").stat().st_mtime + + mgr.install("mybench", env_type=EnvType.LOCAL, module_path=module_path) + assert (mgr.env_path("mybench") / ".installed").stat().st_mtime == mtime + + def test_force_reinstalls(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + mgr.install("mybench", env_type=EnvType.LOCAL, module_path=module_path) + old_marker = json.loads((mgr.env_path("mybench") / ".installed").read_text()) + + mgr.install("mybench", env_type=EnvType.LOCAL, force=True, module_path=module_path) + new_marker = json.loads((mgr.env_path("mybench") / ".installed").read_text()) + + assert new_marker["local"]["installed_at"] >= old_marker["local"]["installed_at"] + + def test_runs_setup_sh_without_virtual_env(self, tmp_path: Path) -> None: + """setup.sh runs with cwd=env_dir but NOT VIRTUAL_ENV.""" + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=True) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + env_dir = mgr.install("mybench", env_type=EnvType.LOCAL, module_path=module_path) + + assert (env_dir / "data" / "setup_ran.txt").is_file() + + def test_installs_requirements_into_current_python(self, tmp_path: Path) -> None: + """Local install must call uv pip install --python sys.executable.""" + module_path = _create_fake_package(tmp_path, with_requirements=True, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + pip_calls: list[list[str]] = [] + + def capture_run(cmd, **kwargs): + if isinstance(cmd, list) and "pip" in cmd and "install" in cmd: + pip_calls.append(list(cmd)) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=capture_run): + mgr.install("mybench", env_type=EnvType.LOCAL, module_path=module_path) + + assert len(pip_calls) == 1 + python_idx = pip_calls[0].index("--python") + 1 + assert pip_calls[0][python_idx] == sys.executable + + +# --------------------------------------------------------------------------- +# Docker install +# --------------------------------------------------------------------------- + + +class TestDockerInstall: + """Docker install builds an image with deps baked in. + + The docker runner needs the image tag from the marker. + """ + + def test_builds_image_and_writes_marker(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=True, with_setup=True) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + dockerfiles: list[str] = [] + + def capture_run(cmd, **kwargs): + if cmd[0] == "docker": + if cmd[1] == "build": + df = Path(cmd[-1]) / "Dockerfile" + if df.exists(): + dockerfiles.append(df.read_text()) + if cmd[1:3] == ["image", "inspect"]: + return _docker_mock_result(returncode=1) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=capture_run): + env_dir = mgr.install("mybench", env_type=EnvType.DOCKER, module_path=module_path) + + assert len(dockerfiles) == 1 + assert "requirements.txt" in dockerfiles[0] + assert "setup.sh" in dockerfiles[0] + + marker = json.loads((env_dir / ".installed").read_text()) + assert "docker" in marker + assert "image" in marker["docker"] + assert "installed_at" in marker["docker"] + + def test_reuses_existing_image(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=True, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + build_called = [] + + def side_effect(cmd, **kwargs): + if cmd[0] == "docker": + if cmd[1] == "build": + build_called.append(True) + if cmd[1:3] == ["image", "inspect"]: + return _docker_mock_result(returncode=0) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=side_effect): + mgr.install("mybench", env_type=EnvType.DOCKER, module_path=module_path) + + assert len(build_called) == 0 + + def test_force_rebuilds(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=True, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + build_calls: list[bool] = [] + + def side_effect(cmd, **kwargs): + if cmd[0] == "docker": + if cmd[1] == "build": + build_calls.append(True) + if cmd[1:3] == ["image", "inspect"]: + return _docker_mock_result(returncode=0) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=side_effect): + mgr.install("mybench", env_type=EnvType.DOCKER, force=True, module_path=module_path) + + assert len(build_calls) == 1 + + def test_includes_system_deps(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=True, with_setup=False, with_system_deps=True) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + dockerfiles: list[str] = [] + + def capture_run(cmd, **kwargs): + if cmd[0] == "docker": + if cmd[1] == "build": + df = Path(cmd[-1]) / "Dockerfile" + if df.exists(): + dockerfiles.append(df.read_text()) + if cmd[1:3] == ["image", "inspect"]: + return _docker_mock_result(returncode=1) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=capture_run): + mgr.install("mybench", env_type=EnvType.DOCKER, module_path=module_path) + + assert "apt-get install -y curl wget" in dockerfiles[0] + + def test_content_hash_differs(self, tmp_path: Path) -> None: + module_path_a = _create_fake_package(tmp_path, with_requirements=True, with_setup=False) + module_path_b = _create_fake_package(tmp_path, with_requirements=True, with_setup=False) + + parts_b = module_path_b.split(".") + pkg_dir_b = tmp_path + for part in parts_b[:-1]: + pkg_dir_b = pkg_dir_b / part + (pkg_dir_b / "requirements.txt").write_text("numpy\npandas\n") + importlib.invalidate_caches() + + tags: list[str] = [] + + def capture_run(cmd, **kwargs): + if cmd[0] == "docker": + if cmd[1] == "build": + idx = list(cmd).index("-t") + tags.append(cmd[idx + 1]) + if cmd[1:3] == ["image", "inspect"]: + return _docker_mock_result(returncode=1) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + with mock.patch("subprocess.run", side_effect=capture_run): + mgr.install("a", env_type=EnvType.DOCKER, module_path=module_path_a) + mgr.install("b", env_type=EnvType.DOCKER, module_path=module_path_b) + + assert tags[0].split(":")[-1] != tags[1].split(":")[-1] + + +# --------------------------------------------------------------------------- +# Coexistence +# --------------------------------------------------------------------------- + + +class TestCoexistence: + """Multiple env types can coexist for the same name. + + Runners pick whichever env type they need. + """ + + def test_venv_and_local_coexist(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + mgr.install("mybench", env_type=EnvType.VENV, module_path=module_path) + mgr.install("mybench", env_type=EnvType.LOCAL, module_path=module_path) + + assert mgr.is_installed("mybench", env_type=EnvType.VENV) + assert mgr.is_installed("mybench", env_type=EnvType.LOCAL) + assert (mgr.env_path("mybench") / "venv").is_dir() + + marker = json.loads((mgr.env_path("mybench") / ".installed").read_text()) + assert "venv" in marker + assert "local" in marker + + def test_venv_and_docker_coexist(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + mgr.install("mybench", env_type=EnvType.VENV, module_path=module_path) + + def docker_side_effect(cmd, **kwargs): + if cmd[0] == "docker": + if cmd[1:3] == ["image", "inspect"]: + return _docker_mock_result(returncode=1) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=docker_side_effect): + mgr.install("mybench", env_type=EnvType.DOCKER, module_path=module_path) + + assert mgr.is_installed("mybench", env_type=EnvType.VENV) + assert mgr.is_installed("mybench", env_type=EnvType.DOCKER) + + def test_all_three_coexist(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + mgr.install("mybench", env_type=EnvType.VENV, module_path=module_path) + mgr.install("mybench", env_type=EnvType.LOCAL, module_path=module_path) + + def docker_side_effect(cmd, **kwargs): + if cmd[0] == "docker": + if cmd[1:3] == ["image", "inspect"]: + return _docker_mock_result(returncode=1) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=docker_side_effect): + mgr.install("mybench", env_type=EnvType.DOCKER, module_path=module_path) + + marker = json.loads((mgr.env_path("mybench") / ".installed").read_text()) + assert set(marker.keys()) == {"venv", "local", "docker"} + + def test_force_reinstall_one_preserves_others(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + mgr.install("mybench", env_type=EnvType.VENV, module_path=module_path) + mgr.install("mybench", env_type=EnvType.LOCAL, module_path=module_path) + + old_marker = json.loads((mgr.env_path("mybench") / ".installed").read_text()) + old_local_at = old_marker["local"]["installed_at"] + + mgr.install("mybench", env_type=EnvType.VENV, force=True, module_path=module_path) + + new_marker = json.loads((mgr.env_path("mybench") / ".installed").read_text()) + assert "venv" in new_marker + assert "local" in new_marker + assert new_marker["local"]["installed_at"] == old_local_at + + +# --------------------------------------------------------------------------- +# Uninstall +# --------------------------------------------------------------------------- + + +class TestUninstall: + """Uninstall removes the specified env type without affecting others. + + When the last env type is removed, the whole directory is cleaned up. + """ + + def test_uninstall_venv(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + mgr.install("mybench", module_path=module_path) + mgr.uninstall("mybench", env_type=EnvType.VENV) + + assert not mgr.is_installed("mybench", env_type=EnvType.VENV) + assert not mgr.env_path("mybench").exists() + + def test_uninstall_local(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + mgr.install("mybench", env_type=EnvType.LOCAL, module_path=module_path) + mgr.uninstall("mybench", env_type=EnvType.LOCAL) + + assert not mgr.is_installed("mybench", env_type=EnvType.LOCAL) + + def test_uninstall_docker_removes_image(self, tmp_path: Path) -> None: + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + env_dir = mgr.env_path("mybench") + env_dir.mkdir(parents=True) + + image_tag = "mybench:abc123" + (env_dir / ".installed").write_text( + json.dumps({"docker": {"installed_at": "2026-01-01T00:00:00Z", "image": image_tag}}) + ) + + rmi_calls: list[list[str]] = [] + + def side_effect(cmd, **kwargs): + if cmd[0] == "docker" and cmd[1] == "rmi": + rmi_calls.append(list(cmd)) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=side_effect): + mgr.uninstall("mybench", env_type=EnvType.DOCKER) + + assert len(rmi_calls) == 1 + assert rmi_calls[0] == ["docker", "rmi", image_tag] + + def test_uninstall_all(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + mgr.install("mybench", env_type=EnvType.VENV, module_path=module_path) + mgr.install("mybench", env_type=EnvType.LOCAL, module_path=module_path) + + mgr.uninstall("mybench") + + assert not mgr.env_path("mybench").exists() + assert not mgr.is_installed("mybench") + + def test_uninstall_one_keeps_others(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + mgr.install("mybench", env_type=EnvType.VENV, module_path=module_path) + mgr.install("mybench", env_type=EnvType.LOCAL, module_path=module_path) + + mgr.uninstall("mybench", env_type=EnvType.VENV) + + assert not mgr.is_installed("mybench", env_type=EnvType.VENV) + assert mgr.is_installed("mybench", env_type=EnvType.LOCAL) + assert not (mgr.env_path("mybench") / "venv").exists() + assert mgr.env_path("mybench").exists() + + def test_uninstall_last_removes_dir(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + mgr.install("mybench", env_type=EnvType.VENV, module_path=module_path) + mgr.install("mybench", env_type=EnvType.LOCAL, module_path=module_path) + + mgr.uninstall("mybench", env_type=EnvType.VENV) + assert mgr.env_path("mybench").exists() + + mgr.uninstall("mybench", env_type=EnvType.LOCAL) + assert not mgr.env_path("mybench").exists() + + def test_uninstall_nonexistent_is_noop(self, tmp_path: Path) -> None: + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + mgr.uninstall("nonexistent") + mgr.uninstall("nonexistent", env_type=EnvType.VENV) + + def test_uninstall_all_with_docker_removes_image(self, tmp_path: Path) -> None: + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + env_dir = mgr.env_path("mybench") + env_dir.mkdir(parents=True) + + image_tag = "mybench:abc123" + (env_dir / ".installed").write_text( + json.dumps( + { + "venv": {"installed_at": "2026-01-01T00:00:00Z"}, + "docker": {"installed_at": "2026-01-01T00:00:00Z", "image": image_tag}, + } + ) + ) + (env_dir / "venv").mkdir() + + rmi_calls: list[list[str]] = [] + + def side_effect(cmd, **kwargs): + if cmd[0] == "docker" and cmd[1] == "rmi": + rmi_calls.append(list(cmd)) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=side_effect): + mgr.uninstall("mybench") + + assert len(rmi_calls) == 1 + assert not env_dir.exists() + + +# --------------------------------------------------------------------------- +# Queries +# --------------------------------------------------------------------------- + + +class TestQueries: + """The list commands and evaluate flow depend on querying install state.""" + + def test_is_installed_any(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + assert not mgr.is_installed("mybench") + + mgr.install("mybench", env_type=EnvType.LOCAL, module_path=module_path) + assert mgr.is_installed("mybench") + assert not mgr.is_installed("mybench", env_type=EnvType.VENV) + assert mgr.is_installed("mybench", env_type=EnvType.LOCAL) + + def test_get_info(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + assert mgr.get_info("mybench") is None + + mgr.install("mybench", env_type=EnvType.VENV, module_path=module_path) + mgr.install("mybench", env_type=EnvType.LOCAL, module_path=module_path) + + info = mgr.get_info("mybench") + assert info is not None + assert info["name"] == "mybench" + assert "venv" in info["environments"] + assert "local" in info["environments"] + assert "installed_at" in info["environments"]["venv"] + assert "python" in info["environments"]["local"] + + def test_list_installed(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + assert mgr.list_installed() == [] + + mgr.install("alpha", module_path=module_path) + mgr.install("beta", module_path=module_path) + + result = mgr.list_installed() + assert len(result) == 2 + names = [r["name"] for r in result] + assert names == ["alpha", "beta"] + assert all("venv" in r["environments"] for r in result) + + def test_list_installed_includes_env_details(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + mgr.install("mybench", env_type=EnvType.VENV, module_path=module_path) + mgr.install("mybench", env_type=EnvType.LOCAL, module_path=module_path) + + result = mgr.list_installed() + assert len(result) == 1 + envs = result[0]["environments"] + assert "venv" in envs + assert "local" in envs + assert "installed_at" in envs["venv"] + assert "python" in envs["local"] + + def test_list_installed_nested_names(self, tmp_path: Path) -> None: + """Names like benchmarks/tau2 should work with list_installed.""" + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + mgr.install("benchmarks/tau2", module_path=module_path) + mgr.install("agents/tool_calling", module_path=module_path) + + result = mgr.list_installed() + names = [r["name"] for r in result] + assert "benchmarks/tau2" in names + assert "agents/tool_calling" in names + + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- + + +class TestPaths: + """Path helpers for locating data and venv directories.""" + + def test_env_path(self, tmp_path: Path) -> None: + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + assert mgr.env_path("tau2") == tmp_path / "envs" / "tau2" + assert mgr.env_path("benchmarks/tau2") == tmp_path / "envs" / "benchmarks" / "tau2" + + def test_venv_python(self, tmp_path: Path) -> None: + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + expected = str(tmp_path / "envs" / "tau2" / "venv" / "bin" / "python") + assert mgr.venv_python("tau2") == expected + + def test_default_base_dir(self) -> None: + mgr = EnvironmentManager() + assert mgr.base_dir == Path.home() / ".exgentic" + + +# --------------------------------------------------------------------------- +# Markers +# --------------------------------------------------------------------------- + + +class TestMarkers: + """Marker file must be robust against corruption and old formats.""" + + def test_corrupted_marker_treated_as_empty(self, tmp_path: Path) -> None: + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + env_dir = mgr.env_path("mybench") + env_dir.mkdir(parents=True) + (env_dir / ".installed").write_text("not json") + + assert not mgr.is_installed("mybench") + assert mgr.get_info("mybench") is None + + def test_non_dict_marker_treated_as_empty(self, tmp_path: Path) -> None: + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + env_dir = mgr.env_path("mybench") + env_dir.mkdir(parents=True) + (env_dir / ".installed").write_text('"just a string"') + + assert not mgr.is_installed("mybench") + + def test_empty_dict_marker_means_not_installed(self, tmp_path: Path) -> None: + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + env_dir = mgr.env_path("mybench") + env_dir.mkdir(parents=True) + (env_dir / ".installed").write_text("{}") + + assert not mgr.is_installed("mybench") + assert mgr.get_info("mybench") is None + + +# --------------------------------------------------------------------------- +# Failure modes +# --------------------------------------------------------------------------- + + +class TestFailureModes: + def test_missing_uv(self, tmp_path: Path) -> None: + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + with mock.patch("shutil.which", return_value=None): + with pytest.raises(RuntimeError, match="Could not find 'uv'"): + mgr.install("mybench") + + def test_broken_setup_sh_no_marker(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + parts = module_path.split(".") + pkg_dir = tmp_path + for part in parts[:-1]: + pkg_dir = pkg_dir / part + broken = pkg_dir / "setup.sh" + broken.write_text("#!/usr/bin/env bash\nexit 1\n") + broken.chmod(broken.stat().st_mode | stat.S_IEXEC) + importlib.invalidate_caches() + + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + with pytest.raises(subprocess.CalledProcessError): + mgr.install("mybench", module_path=module_path) + + assert not mgr.is_installed("mybench", env_type=EnvType.VENV) + + def test_missing_system_dep(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False, with_system_deps=True) + parts = module_path.split(".") + pkg_dir = tmp_path + for part in parts[:-1]: + pkg_dir = pkg_dir / part + (pkg_dir / "system-deps.txt").write_text("nonexistent_tool_xyz\n") + importlib.invalidate_caches() + + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + original_which = shutil.which + + def which_no_fake(name): + if name == "nonexistent_tool_xyz": + return None + return original_which(name) + + with mock.patch("exgentic.environment.helpers.shutil.which", side_effect=which_no_fake): + with mock.patch("exgentic.environment.helpers._dpkg_installed", return_value=False): + with pytest.raises(RuntimeError, match="nonexistent_tool_xyz"): + mgr.install("mybench", module_path=module_path) + + def test_venv_failure_preserves_coexisting_envs(self, tmp_path: Path) -> None: + """If venv install fails, local install must be unaffected.""" + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + mgr.install("mybench", env_type=EnvType.LOCAL, module_path=module_path) + + def fail_venv(cmd, **kwargs): + if isinstance(cmd, list) and "venv" in cmd: + raise subprocess.CalledProcessError(1, cmd) + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=fail_venv): + with pytest.raises(subprocess.CalledProcessError): + mgr.install("mybench", env_type=EnvType.VENV, module_path=module_path) + + assert mgr.is_installed("mybench", env_type=EnvType.LOCAL) + assert not mgr.is_installed("mybench", env_type=EnvType.VENV) + + def test_invalid_env_type(self, tmp_path: Path) -> None: + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + with pytest.raises(ValueError): + mgr.install("mybench", env_type="invalid") + + +# --------------------------------------------------------------------------- +# State transitions +# --------------------------------------------------------------------------- + + +class TestStateMachine: + def test_install_uninstall_install_cycle(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + env1 = mgr.install("mybench", module_path=module_path) + assert mgr.is_installed("mybench") + + mgr.uninstall("mybench") + assert not mgr.is_installed("mybench") + assert not env1.exists() + + env2 = mgr.install("mybench", module_path=module_path) + assert mgr.is_installed("mybench") + assert env2 == env1 + + def test_double_uninstall(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + mgr.install("mybench", module_path=module_path) + mgr.uninstall("mybench") + mgr.uninstall("mybench") + + assert not mgr.is_installed("mybench") + + def test_install_without_module_path(self, tmp_path: Path) -> None: + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + env_dir = mgr.install("mybench") + + assert mgr.is_installed("mybench", env_type=EnvType.VENV) + assert (env_dir / "venv" / "bin" / "python").exists() + + def test_local_install_without_module_path(self, tmp_path: Path) -> None: + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + env_dir = mgr.install("mybench", env_type=EnvType.LOCAL) + + assert mgr.is_installed("mybench", env_type=EnvType.LOCAL) + assert env_dir.is_dir() + + +# --------------------------------------------------------------------------- +# Convenience accessors +# --------------------------------------------------------------------------- + + +class TestConvenienceAccessors: + """Runners need quick access to docker image tags and local Python paths.""" + + def test_docker_image_returns_tag(self, tmp_path: Path) -> None: + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + env_dir = mgr.env_path("mybench") + env_dir.mkdir(parents=True) + (env_dir / ".installed").write_text( + json.dumps({"docker": {"installed_at": "2026-01-01T00:00:00Z", "image": "mybench:abc123"}}) + ) + + assert mgr.docker_image("mybench") == "mybench:abc123" + + def test_docker_image_returns_none_when_not_installed(self, tmp_path: Path) -> None: + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + assert mgr.docker_image("mybench") is None + + def test_local_python_returns_path(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + mgr.install("mybench", env_type=EnvType.LOCAL, module_path=module_path) + + assert mgr.local_python("mybench") == sys.executable + + def test_local_python_returns_none_when_not_installed(self, tmp_path: Path) -> None: + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + assert mgr.local_python("mybench") is None + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class TestFindPackageFile: + def test_finds_file(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=True) + result = find_package_file(module_path, "requirements.txt") + assert result is not None + assert result.name == "requirements.txt" + + def test_returns_none_for_missing(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + assert find_package_file(module_path, "nonexistent.txt") is None + + +class TestRequireUv: + def test_returns_path(self) -> None: + path = require_uv() + assert "uv" in Path(path).name + + def test_raises_when_missing(self) -> None: + with mock.patch("shutil.which", return_value=None): + with pytest.raises(RuntimeError, match="Could not find 'uv'"): + require_uv() + + +class TestBuildSubprocessEnv: + """build_subprocess_env strips vars that could interfere with installs.""" + + def test_strips_virtual_env(self) -> None: + with mock.patch.dict(os.environ, {"VIRTUAL_ENV": "/some/venv"}, clear=False): + env = build_subprocess_env() + assert "VIRTUAL_ENV" not in env + + def test_strips_conda_vars(self) -> None: + with mock.patch.dict( + os.environ, + {"CONDA_DEFAULT_ENV": "base", "CONDA_PREFIX": "/opt/conda"}, + clear=False, + ): + env = build_subprocess_env() + assert "CONDA_DEFAULT_ENV" not in env + assert "CONDA_PREFIX" not in env + + def test_strips_uv_prefix_vars(self) -> None: + with mock.patch.dict( + os.environ, + {"UV_INDEX_URL": "https://evil.example.com", "UV_PYTHON": "3.8"}, + clear=False, + ): + env = build_subprocess_env() + assert "UV_INDEX_URL" not in env + assert "UV_PYTHON" not in env + + def test_strips_pip_prefix_vars(self) -> None: + with mock.patch.dict(os.environ, {"PIP_INDEX_URL": "https://evil.example.com"}, clear=False): + env = build_subprocess_env() + assert "PIP_INDEX_URL" not in env + + def test_preserves_path_and_home(self) -> None: + env = build_subprocess_env() + assert "PATH" in env + assert "HOME" in env + + def test_sets_git_lfs_skip_smudge(self) -> None: + env = build_subprocess_env() + assert env["GIT_LFS_SKIP_SMUDGE"] == "1" + + +# --------------------------------------------------------------------------- +# Project root & packages +# --------------------------------------------------------------------------- + + +class TestVenvProjectRoot: + """Venv backend installs a Python project from project_root.""" + + def test_project_root_installs_project(self, tmp_path: Path) -> None: + project = _create_fake_project(tmp_path) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + pip_calls: list[list[str]] = [] + + def capture_run(cmd, **kwargs): + if isinstance(cmd, list) and "pip" in cmd and "install" in cmd: + pip_calls.append(list(cmd)) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=capture_run): + mgr.install("mybench", project_root=project) + + # First pip call should install the project root. + assert len(pip_calls) >= 1 + assert str(project) in pip_calls[0] + + def test_packages_installed_into_venv(self, tmp_path: Path) -> None: + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + pip_calls: list[list[str]] = [] + + def capture_run(cmd, **kwargs): + if isinstance(cmd, list) and "pip" in cmd and "install" in cmd: + pip_calls.append(list(cmd)) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=capture_run): + mgr.install("mybench", packages=["numpy", "pandas"]) + + assert len(pip_calls) == 1 + assert "numpy" in pip_calls[0] + assert "pandas" in pip_calls[0] + + def test_project_root_and_packages_combined(self, tmp_path: Path) -> None: + project = _create_fake_project(tmp_path) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + pip_calls: list[list[str]] = [] + + def capture_run(cmd, **kwargs): + if isinstance(cmd, list) and "pip" in cmd and "install" in cmd: + pip_calls.append(list(cmd)) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=capture_run): + mgr.install("mybench", project_root=project, packages=["numpy"]) + + # Two separate pip calls: project root, then packages. + assert len(pip_calls) == 2 + assert str(project) in pip_calls[0] + assert "numpy" in pip_calls[1] + + def test_without_project_root_still_works(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + env_dir = mgr.install("mybench", module_path=module_path) + + assert (env_dir / "venv" / "bin" / "python").exists() + assert mgr.is_installed("mybench", env_type=EnvType.VENV) + + +class TestLocalProjectRoot: + """Local backend installs project_root and packages into host Python.""" + + def test_project_root_installs_into_host_python(self, tmp_path: Path) -> None: + project = _create_fake_project(tmp_path) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + pip_calls: list[list[str]] = [] + + def capture_run(cmd, **kwargs): + if isinstance(cmd, list) and "pip" in cmd and "install" in cmd: + pip_calls.append(list(cmd)) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=capture_run): + mgr.install("mybench", env_type=EnvType.LOCAL, project_root=project) + + assert len(pip_calls) == 1 + assert str(project) in pip_calls[0] + python_idx = pip_calls[0].index("--python") + 1 + assert pip_calls[0][python_idx] == sys.executable + + def test_packages_installs_into_host_python(self, tmp_path: Path) -> None: + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + pip_calls: list[list[str]] = [] + + def capture_run(cmd, **kwargs): + if isinstance(cmd, list) and "pip" in cmd and "install" in cmd: + pip_calls.append(list(cmd)) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=capture_run): + mgr.install("mybench", env_type=EnvType.LOCAL, packages=["numpy"]) + + assert len(pip_calls) == 1 + assert "numpy" in pip_calls[0] + + +class TestDockerProjectRoot: + """Docker backend uses two-layer build when project_root is provided.""" + + def test_project_root_triggers_two_layer_build(self, tmp_path: Path) -> None: + project = _create_fake_project(tmp_path) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + dockerfiles: list[str] = [] + + def capture_run(cmd, **kwargs): + if isinstance(cmd, list) and cmd[0] == "docker": + if cmd[1] == "build": + # Read dockerfile from -f argument. + if "-f" in cmd: + df_idx = list(cmd).index("-f") + 1 + df_path = Path(cmd[df_idx]) + if df_path.exists(): + dockerfiles.append(df_path.read_text()) + if cmd[1:3] == ["image", "inspect"]: + return _docker_mock_result(returncode=1) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=capture_run): + mgr.install("mybench", env_type=EnvType.DOCKER, project_root=project) + + assert len(dockerfiles) == 1 + df = dockerfiles[0] + assert "COPY pyproject.toml" in df + assert "COPY src/ src/" in df + assert "uv pip install --no-cache ." in df + assert "uv pip install --no-cache --no-deps ." in df + + def test_docker_build_context_is_project_root(self, tmp_path: Path) -> None: + project = _create_fake_project(tmp_path) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + build_contexts: list[str] = [] + + def capture_run(cmd, **kwargs): + if isinstance(cmd, list) and cmd[0] == "docker": + if cmd[1] == "build": + build_contexts.append(cmd[-1]) + if cmd[1:3] == ["image", "inspect"]: + return _docker_mock_result(returncode=1) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=capture_run): + mgr.install("mybench", env_type=EnvType.DOCKER, project_root=project) + + # Two builds: base image uses project_root as context, bench uses a temp dir. + assert len(build_contexts) == 2 + assert build_contexts[0] == str(project) + assert "exgentic-bench-" in build_contexts[1] + + def test_docker_without_project_root_uses_tmp_dir(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=True, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + build_contexts: list[str] = [] + + def capture_run(cmd, **kwargs): + if isinstance(cmd, list) and cmd[0] == "docker": + if cmd[1] == "build": + build_contexts.append(cmd[-1]) + if cmd[1:3] == ["image", "inspect"]: + return _docker_mock_result(returncode=1) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=capture_run): + mgr.install("mybench", env_type=EnvType.DOCKER, module_path=module_path) + + assert len(build_contexts) == 1 + assert "exgentic-docker-" in build_contexts[0] + + def test_docker_packages_in_dockerfile(self, tmp_path: Path) -> None: + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + dockerfiles: list[str] = [] + + def capture_run(cmd, **kwargs): + if isinstance(cmd, list) and cmd[0] == "docker": + if cmd[1] == "build": + df = Path(cmd[-1]) / "Dockerfile" + if df.exists(): + dockerfiles.append(df.read_text()) + if cmd[1:3] == ["image", "inspect"]: + return _docker_mock_result(returncode=1) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=capture_run): + mgr.install("mybench", env_type=EnvType.DOCKER, packages=["numpy", "pandas"]) + + assert len(dockerfiles) == 1 + assert "uv pip install --no-cache numpy pandas" in dockerfiles[0] + + def test_content_hash_includes_project_root(self, tmp_path: Path) -> None: + project = _create_fake_project(tmp_path, name="proj-a") + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + tags: list[str] = [] + + def capture_run(cmd, **kwargs): + if isinstance(cmd, list) and cmd[0] == "docker": + if cmd[1] == "build": + idx = list(cmd).index("-t") + tags.append(cmd[idx + 1]) + if cmd[1:3] == ["image", "inspect"]: + return _docker_mock_result(returncode=1) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=capture_run): + mgr.install("a", env_type=EnvType.DOCKER, project_root=project) + mgr.install("b", env_type=EnvType.DOCKER) + + # project_root install: 2 builds (base + bench); no-project_root install: 1 build. + assert len(tags) == 3 + bench_tag_a = tags[1] # bench tag for "a" + single_tag_b = tags[2] # single-image tag for "b" + assert bench_tag_a.split(":")[-1] != single_tag_b.split(":")[-1] + + def test_project_root_with_force_includes(self, tmp_path: Path) -> None: + project = _create_fake_project(tmp_path, name="withfi") + # Add force-include to pyproject.toml. + pyproject = project / "pyproject.toml" + pyproject.write_text( + pyproject.read_text() + + textwrap.dedent( + """\ + + [tool.hatch.build.targets.wheel.force-include] + "configs/" = "withfi/configs/" + """ + ) + ) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + dockerfiles: list[str] = [] + + def capture_run(cmd, **kwargs): + if isinstance(cmd, list) and cmd[0] == "docker": + if cmd[1] == "build": + if "-f" in cmd: + df_idx = list(cmd).index("-f") + 1 + df_path = Path(cmd[df_idx]) + if df_path.exists(): + dockerfiles.append(df_path.read_text()) + if cmd[1:3] == ["image", "inspect"]: + return _docker_mock_result(returncode=1) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=capture_run): + mgr.install("mybench", env_type=EnvType.DOCKER, project_root=project) + + assert len(dockerfiles) == 1 + assert "mkdir -p 'configs/'" in dockerfiles[0] + + def test_two_builds_when_project_root_provided(self, tmp_path: Path) -> None: + """Two docker build calls: one base image, one bench image.""" + project = _create_fake_project(tmp_path) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + base_builds: list[list] = [] + bench_builds: list[list] = [] + + def capture_run(cmd, **kwargs): + if isinstance(cmd, list) and cmd[0] == "docker": + if cmd[1] == "build": + if "-f" in cmd: + base_builds.append(list(cmd)) + else: + bench_builds.append(list(cmd)) + if cmd[1:3] == ["image", "inspect"]: + return _docker_mock_result(returncode=1) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=capture_run): + mgr.install("mybench", env_type=EnvType.DOCKER, project_root=project) + + assert len(base_builds) == 1 + assert len(bench_builds) == 1 + + def test_base_image_tag_has_prefix(self, tmp_path: Path) -> None: + """Base image tag must start with 'exgentic-base:'.""" + project = _create_fake_project(tmp_path) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + base_tags: list[str] = [] + + def capture_run(cmd, **kwargs): + if isinstance(cmd, list) and cmd[0] == "docker": + if cmd[1] == "build" and "-f" in cmd: + idx = list(cmd).index("-t") + base_tags.append(cmd[idx + 1]) + if cmd[1:3] == ["image", "inspect"]: + return _docker_mock_result(returncode=1) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=capture_run): + mgr.install("mybench", env_type=EnvType.DOCKER, project_root=project) + + assert len(base_tags) == 1 + assert base_tags[0].startswith("exgentic-base:") + + def test_bench_image_from_base(self, tmp_path: Path) -> None: + """Bench Dockerfile must start with FROM exgentic-base:...""" + project = _create_fake_project(tmp_path) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + bench_dockerfiles: list[str] = [] + + def capture_run(cmd, **kwargs): + if isinstance(cmd, list) and cmd[0] == "docker": + if cmd[1] == "build" and "-f" not in cmd: + df = Path(cmd[-1]) / "Dockerfile" + if df.exists(): + bench_dockerfiles.append(df.read_text()) + if cmd[1:3] == ["image", "inspect"]: + return _docker_mock_result(returncode=1) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=capture_run): + mgr.install("mybench", env_type=EnvType.DOCKER, project_root=project) + + assert len(bench_dockerfiles) == 1 + assert bench_dockerfiles[0].startswith("FROM exgentic-base:") + + def test_base_image_tag_stored_in_marker(self, tmp_path: Path) -> None: + """Marker must record base_image so uninstall can clean it up.""" + project = _create_fake_project(tmp_path) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + def capture_run(cmd, **kwargs): + if isinstance(cmd, list) and cmd[0] == "docker": + if cmd[1:3] == ["image", "inspect"]: + return _docker_mock_result(returncode=1) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=capture_run): + mgr.install("mybench", env_type=EnvType.DOCKER, project_root=project) + + marker = json.loads((mgr.env_path("mybench") / ".installed").read_text()) + assert "base_image" in marker["docker"] + assert marker["docker"]["base_image"].startswith("exgentic-base:") + + def test_uninstall_attempts_base_image_removal(self, tmp_path: Path) -> None: + """uninstall() attempts to remove the base image (rmi silently fails if still in use).""" + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + env_dir = mgr.env_path("mybench") + env_dir.mkdir(parents=True) + (env_dir / ".installed").write_text( + json.dumps( + { + "docker": { + "installed_at": "2026-01-01T00:00:00Z", + "image": "mybench:abc123", + "base_image": "exgentic-base:def456", + } + } + ) + ) + + rmi_calls: list[str] = [] + + def side_effect(cmd, **kwargs): + if cmd[0] == "docker" and cmd[1] == "rmi": + rmi_calls.append(cmd[2]) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=side_effect): + mgr.uninstall("mybench", env_type=EnvType.DOCKER) + + assert "mybench:abc123" in rmi_calls + assert "exgentic-base:def456" in rmi_calls + + def test_base_image_reused_on_second_install(self, tmp_path: Path) -> None: + """Base image is only built once; second bench reuses it.""" + project = _create_fake_project(tmp_path) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + built_images: set[str] = set() + base_builds = 0 + bench_builds = 0 + + def capture_run(cmd, **kwargs): + nonlocal base_builds, bench_builds + if isinstance(cmd, list) and cmd[0] == "docker": + if cmd[1] == "build": + idx = list(cmd).index("-t") + built_images.add(cmd[idx + 1]) + if "-f" in cmd: + base_builds += 1 + else: + bench_builds += 1 + elif cmd[1:3] == ["image", "inspect"]: + tag = cmd[-1] + return _docker_mock_result(returncode=0 if tag in built_images else 1) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=capture_run): + mgr.install("bench-a", env_type=EnvType.DOCKER, project_root=project) + mgr.install("bench-b", env_type=EnvType.DOCKER, project_root=project) + + assert base_builds == 1 + assert bench_builds == 2 + + def test_image_version_changes_base_tag(self, tmp_path: Path) -> None: + """Bumping _IMAGE_VERSION produces a different base tag.""" + from exgentic.environment.docker import DockerBackend + + project = _create_fake_project(tmp_path) + + tag_v1 = DockerBackend._base_image_tag(project) + with mock.patch.object(DockerBackend, "_IMAGE_VERSION", "v99"): + tag_v99 = DockerBackend._base_image_tag(project) + + assert tag_v1.startswith("exgentic-base:") + assert tag_v99.startswith("exgentic-base:") + assert tag_v1 != tag_v99 + + +# --------------------------------------------------------------------------- +# Docker socket +# --------------------------------------------------------------------------- + + +class TestDockerSocket: + """docker_socket=True installs Docker CLI binary in the bench/single image.""" + + def test_docker_socket_adds_cli_to_bench_image(self, tmp_path: Path) -> None: + """docker_socket=True adds Docker CLI RUN to the bench Dockerfile.""" + project = _create_fake_project(tmp_path) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + bench_dockerfiles: list[str] = [] + + def capture_run(cmd, **kwargs): + if isinstance(cmd, list) and cmd[0] == "docker": + if cmd[1] == "build" and "-f" not in cmd: + df = Path(cmd[-1]) / "Dockerfile" + if df.exists(): + bench_dockerfiles.append(df.read_text()) + if cmd[1:3] == ["image", "inspect"]: + return _docker_mock_result(returncode=1) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=capture_run): + mgr.install("mybench", env_type=EnvType.DOCKER, project_root=project, docker_socket=True) + + assert len(bench_dockerfiles) == 1 + assert "docker.com/linux/static" in bench_dockerfiles[0] + + def test_docker_socket_not_in_base_image(self, tmp_path: Path) -> None: + """docker_socket does NOT affect the base image — base must stay lean.""" + project = _create_fake_project(tmp_path) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + base_dockerfiles: list[str] = [] + + def capture_run(cmd, **kwargs): + if isinstance(cmd, list) and cmd[0] == "docker": + if cmd[1] == "build" and "-f" in cmd: + df_idx = list(cmd).index("-f") + 1 + df_path = Path(cmd[df_idx]) + if df_path.exists(): + base_dockerfiles.append(df_path.read_text()) + if cmd[1:3] == ["image", "inspect"]: + return _docker_mock_result(returncode=1) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=capture_run): + mgr.install("mybench", env_type=EnvType.DOCKER, project_root=project, docker_socket=True) + + assert len(base_dockerfiles) == 1 + assert "docker.com/linux/static" not in base_dockerfiles[0] + + def test_docker_socket_in_single_image_path(self, tmp_path: Path) -> None: + """docker_socket=True also adds Docker CLI in single-image (no project_root) path.""" + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + dockerfiles: list[str] = [] + + def capture_run(cmd, **kwargs): + if isinstance(cmd, list) and cmd[0] == "docker": + if cmd[1] == "build": + df = Path(cmd[-1]) / "Dockerfile" + if df.exists(): + dockerfiles.append(df.read_text()) + if cmd[1:3] == ["image", "inspect"]: + return _docker_mock_result(returncode=1) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=capture_run): + mgr.install("mybench", env_type=EnvType.DOCKER, docker_socket=True) + + assert len(dockerfiles) == 1 + assert "docker.com/linux/static" in dockerfiles[0] + + def test_docker_socket_changes_image_hash(self, tmp_path: Path) -> None: + """Same config with and without docker_socket produces different image tags.""" + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + tags: list[str] = [] + + def capture_run(cmd, **kwargs): + if isinstance(cmd, list) and cmd[0] == "docker": + if cmd[1] == "build": + idx = list(cmd).index("-t") + tags.append(cmd[idx + 1]) + if cmd[1:3] == ["image", "inspect"]: + return _docker_mock_result(returncode=1) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=capture_run): + mgr.install("bench-no-socket", env_type=EnvType.DOCKER) + mgr.install("bench-with-socket", env_type=EnvType.DOCKER, docker_socket=True) + + assert len(tags) == 2 + assert tags[0].split(":")[-1] != tags[1].split(":")[-1] + + +# --------------------------------------------------------------------------- +# Docker build environment variable +# --------------------------------------------------------------------------- + + +class TestDockerBuildEnv: + """EXGENTIC_DOCKER_BUILD=1 is set when running setup.sh during image builds.""" + + def test_exgentic_docker_build_in_bench_setup_sh(self, tmp_path: Path) -> None: + """EXGENTIC_DOCKER_BUILD=1 is prepended to the setup.sh RUN in bench image.""" + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=True) + project = _create_fake_project(tmp_path) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + bench_dockerfiles: list[str] = [] + + def capture_run(cmd, **kwargs): + if isinstance(cmd, list) and cmd[0] == "docker": + if cmd[1] == "build" and "-f" not in cmd: + df = Path(cmd[-1]) / "Dockerfile" + if df.exists(): + bench_dockerfiles.append(df.read_text()) + if cmd[1:3] == ["image", "inspect"]: + return _docker_mock_result(returncode=1) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=capture_run): + mgr.install( + "mybench", + env_type=EnvType.DOCKER, + project_root=project, + module_path=module_path, + ) + + assert len(bench_dockerfiles) == 1 + assert "EXGENTIC_DOCKER_BUILD=1 bash /tmp/setup.sh" in bench_dockerfiles[0] + + def test_exgentic_docker_build_in_single_image_setup_sh(self, tmp_path: Path) -> None: + """EXGENTIC_DOCKER_BUILD=1 is also present in the single-image (no project_root) path.""" + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=True) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + dockerfiles: list[str] = [] + + def capture_run(cmd, **kwargs): + if isinstance(cmd, list) and cmd[0] == "docker": + if cmd[1] == "build": + df = Path(cmd[-1]) / "Dockerfile" + if df.exists(): + dockerfiles.append(df.read_text()) + if cmd[1:3] == ["image", "inspect"]: + return _docker_mock_result(returncode=1) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + with mock.patch("subprocess.run", side_effect=capture_run): + mgr.install("mybench", env_type=EnvType.DOCKER, module_path=module_path) + + assert len(dockerfiles) == 1 + assert "EXGENTIC_DOCKER_BUILD=1 bash /tmp/setup.sh" in dockerfiles[0] + + +# --------------------------------------------------------------------------- +# DockerBackend Dockerfile generation +# --------------------------------------------------------------------------- + + +class TestDockerBackendDockerfile: + """Verify the Dockerfile content DockerBackend generates. + + These tests mock subprocess.run to capture the Dockerfile instead of + actually building. They verify critical assumptions: + - exgentic is installed from source when a source dir is provided + - requirements.txt and setup.sh are included + - docker socket installs Docker CLI + - extra dependencies are installed + """ + + def _capture_dockerfiles(self, tmp_path, **install_kwargs): + """Run DockerBackend.install() with mocked docker, return list of Dockerfile contents.""" + from exgentic.environment.docker import DockerBackend + + dockerfiles: list[str] = [] + + def side_effect(cmd, **kwargs): + cmd = list(cmd) + if cmd[0] == "docker" and cmd[1:3] == ["image", "inspect"]: + # Image doesn't exist yet. + return _docker_mock_result(returncode=1) + if cmd[0] == "docker" and cmd[1] == "build": + # Find the Dockerfile. + if "-f" in cmd: + df_path = cmd[cmd.index("-f") + 1] + else: + df_path = str(Path(cmd[-1]) / "Dockerfile") + dockerfiles.append(Path(df_path).read_text()) + return _docker_mock_result() + return _real_subprocess_run(cmd, **kwargs) + + backend = DockerBackend() + env_dir = tmp_path / "env" + env_dir.mkdir(parents=True, exist_ok=True) + + with mock.patch("subprocess.run", side_effect=side_effect): + backend.install(env_dir, **install_kwargs) + + return dockerfiles + + def _capture_dockerfile(self, tmp_path, **install_kwargs): + """Run DockerBackend.install() with mocked docker, return single Dockerfile content.""" + dockerfiles = self._capture_dockerfiles(tmp_path, **install_kwargs) + assert len(dockerfiles) == 1, f"Expected 1 docker build, got {len(dockerfiles)}" + return dockerfiles[0] + + def test_source_install_copies_source(self, tmp_path: Path) -> None: + """When project_root is given, build a base + bench image pair.""" + # Create a fake project root. + proj = tmp_path / "project" + proj.mkdir() + (proj / "pyproject.toml").write_text('[project]\nname = "fakepkg"\nversion = "0.1"\n') + (proj / "README.md").write_text("# Fake\n") + src = proj / "src" / "exgentic" + src.mkdir(parents=True) + (src / "__init__.py").write_text("") + + dockerfiles = self._capture_dockerfiles( + tmp_path, + name="benchmarks/test", + module_path=None, + project_root=proj, + ) + + assert len(dockerfiles) == 2, f"Expected 2 docker builds (base + bench), got {len(dockerfiles)}" + base_df, bench_df = dockerfiles + + # Base image installs exgentic from source. + assert "COPY pyproject.toml" in base_df + assert "COPY src/ src/" in base_df + assert "uv pip install --no-cache ." in base_df + assert "uv pip install --no-cache --no-deps ." in base_df + + # Bench image is layered on top. + assert "FROM exgentic-base:" in bench_df + + def test_pypi_install(self, tmp_path: Path) -> None: + """When packages are given, RUN uv pip install.""" + df = self._capture_dockerfile( + tmp_path, + name="benchmarks/test", + module_path=None, + packages=["exgentic==1.2.3"], + ) + + assert "uv pip install --no-cache exgentic==1.2.3" in df + assert "COPY src/" not in df + + def test_requirements_included(self, tmp_path: Path) -> None: + """requirements.txt from module_path is installed.""" + module_path = _create_fake_package(tmp_path, with_requirements=True, with_setup=False) + + df = self._capture_dockerfile( + tmp_path, + name="benchmarks/test", + module_path=module_path, + packages=["exgentic==1.0"], + ) + + assert "requirements.txt" in df + assert "uv pip install --no-cache -r" in df + + def test_setup_sh_included(self, tmp_path: Path) -> None: + """setup.sh from module_path is run during build.""" + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=True) + + df = self._capture_dockerfile( + tmp_path, + name="benchmarks/test", + module_path=module_path, + packages=["exgentic==1.0"], + ) + + assert "setup.sh" in df + assert "EXGENTIC_DOCKER_BUILD=1 bash /tmp/setup.sh" in df + + def test_docker_socket_installs_cli(self, tmp_path: Path) -> None: + """docker_socket=True installs Docker CLI in the image.""" + df = self._capture_dockerfile( + tmp_path, + name="benchmarks/test", + module_path=None, + docker_socket=True, + ) + + assert "docker-27.5.1.tgz" in df + assert "/usr/local/bin docker/docker" in df + + def test_extra_dependencies(self, tmp_path: Path) -> None: + """Extra packages are pip-installed.""" + df = self._capture_dockerfile( + tmp_path, + name="benchmarks/test", + module_path=None, + packages=["numpy", "pandas"], + ) + + assert "uv pip install --no-cache numpy pandas" in df + + def test_no_exgentic_without_packages(self, tmp_path: Path) -> None: + """Without packages or project_root, no exgentic install lines.""" + df = self._capture_dockerfile( + tmp_path, + name="benchmarks/test", + module_path=None, + ) + + assert "COPY src/" not in df + assert "exgentic" not in df.lower() or "exgentic-docker" in df.lower() + + def test_image_tag_deterministic(self, tmp_path: Path) -> None: + """Same inputs produce the same image tag.""" + from exgentic.environment.docker import DockerBackend + + tag1 = DockerBackend._image_tag("benchmarks/test", None, docker_socket=True) + tag2 = DockerBackend._image_tag("benchmarks/test", None, docker_socket=True) + assert tag1 == tag2 + + def test_image_tag_changes_with_packages(self, tmp_path: Path) -> None: + """Different packages produce different tags.""" + from exgentic.environment.docker import DockerBackend + + tag1 = DockerBackend._image_tag("benchmarks/test", None, packages=["exgentic==1.0"]) + tag2 = DockerBackend._image_tag("benchmarks/test", None, packages=["exgentic==2.0"]) + assert tag1 != tag2 + + +# --------------------------------------------------------------------------- +# Docker integration (requires Docker/Podman running) +# --------------------------------------------------------------------------- + +_docker_available = shutil.which("docker") is not None +if _docker_available: + try: + subprocess.run(["docker", "info"], check=True, capture_output=True, timeout=5) + except Exception: + _docker_available = False + + +@pytest.mark.skipif(not _docker_available, reason="Docker not available") +class TestDockerIntegration: + """Real Docker tests -- actually build and remove images.""" + + def test_docker_build_and_marker(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + mgr.install("inttest", env_type=EnvType.DOCKER, module_path=module_path) + + assert mgr.is_installed("inttest", env_type=EnvType.DOCKER) + image_tag = mgr.docker_image("inttest") + assert image_tag is not None + + result = subprocess.run(["docker", "image", "inspect", image_tag], check=False, capture_output=True, text=True) + assert result.returncode == 0 + + mgr.uninstall("inttest", env_type=EnvType.DOCKER) + result = subprocess.run(["docker", "image", "inspect", image_tag], check=False, capture_output=True, text=True) + assert result.returncode != 0 + + def test_docker_reuses_existing_image(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + mgr.install("inttest2", env_type=EnvType.DOCKER, module_path=module_path) + tag1 = mgr.docker_image("inttest2") + + mgr.install("inttest2", env_type=EnvType.DOCKER, force=True, module_path=module_path) + tag2 = mgr.docker_image("inttest2") + + assert tag1 == tag2 + mgr.uninstall("inttest2") + + def test_docker_with_requirements(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=True, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + mgr.install("inttest3", env_type=EnvType.DOCKER, module_path=module_path) + + image_tag = mgr.docker_image("inttest3") + assert image_tag is not None + + result = subprocess.run( + ["docker", "run", "--rm", image_tag, "python", "-c", "import requests; print(requests.__version__)"], + check=False, + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert result.stdout.strip() + + mgr.uninstall("inttest3") + + def test_docker_coexists_with_venv(self, tmp_path: Path) -> None: + module_path = _create_fake_package(tmp_path, with_requirements=False, with_setup=False) + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + mgr.install("inttest4", env_type=EnvType.VENV, module_path=module_path) + mgr.install("inttest4", env_type=EnvType.DOCKER, module_path=module_path) + + assert mgr.is_installed("inttest4", env_type=EnvType.VENV) + assert mgr.is_installed("inttest4", env_type=EnvType.DOCKER) + + mgr.uninstall("inttest4", env_type=EnvType.DOCKER) + assert mgr.is_installed("inttest4", env_type=EnvType.VENV) + assert not mgr.is_installed("inttest4", env_type=EnvType.DOCKER) + + mgr.uninstall("inttest4") + + def test_docker_project_root_installs_package(self, tmp_path: Path) -> None: + """Build Docker image with project_root and verify the package is importable inside.""" + project = _create_fake_project(tmp_path, name="mypkg") + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + mgr.install("inttest5", env_type=EnvType.DOCKER, project_root=project) + + image_tag = mgr.docker_image("inttest5") + assert image_tag is not None + + result = subprocess.run( + ["docker", "run", "--rm", image_tag, "python", "-c", "import mypkg; print(mypkg.__version__)"], + check=False, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "0.1.0" + + mgr.uninstall("inttest5") + + def test_venv_project_root_installs_package(self, tmp_path: Path) -> None: + """Create venv with project_root and verify the package is importable inside.""" + project = _create_fake_project(tmp_path, name="mypkg2") + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + mgr.install("inttest6", env_type=EnvType.VENV, project_root=project) + + venv_py = mgr.venv_python("inttest6") + result = subprocess.run( + [venv_py, "-c", "import mypkg2; print(mypkg2.__version__)"], + check=False, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "0.1.0" + + mgr.uninstall("inttest6") + + def test_docker_socket_creates_working_docker_cli(self, tmp_path: Path) -> None: + """docker_socket=True installs a functional Docker CLI binary inside the image.""" + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + mgr.install("inttest-ds", env_type=EnvType.DOCKER, docker_socket=True) + + image_tag = mgr.docker_image("inttest-ds") + assert image_tag is not None + + result = subprocess.run( + ["docker", "run", "--rm", image_tag, "docker", "--version"], + check=False, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + assert "Docker version" in result.stdout + + mgr.uninstall("inttest-ds") + + def test_base_image_shared_between_benchmarks(self, tmp_path: Path) -> None: + """Two benchmarks with the same project_root share the base image (1 base build, 2 bench builds).""" + project = _create_fake_project(tmp_path, name="shared") + mgr = EnvironmentManager(base_dir=tmp_path / "envs") + + # Use a unique name prefix to avoid collisions with other test runs. + mgr.install("inttest-shared-a", env_type=EnvType.DOCKER, project_root=project) + mgr.install("inttest-shared-b", env_type=EnvType.DOCKER, project_root=project) + + tag_a = mgr.docker_image("inttest-shared-a") + tag_b = mgr.docker_image("inttest-shared-b") + assert tag_a is not None + assert tag_b is not None + # Same project_root → same base tag prefix, different bench tags (different names). + assert tag_a != tag_b + assert tag_a.startswith("inttest-shared-a:") + assert tag_b.startswith("inttest-shared-b:") + + # Both images must be runnable. + for tag in (tag_a, tag_b): + result = subprocess.run( + ["docker", "run", "--rm", tag, "python", "-c", "import shared; print(shared.__version__)"], + check=False, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "0.1.0" + + mgr.uninstall("inttest-shared-a") + mgr.uninstall("inttest-shared-b") diff --git a/labs/AgentStream/exgentic/tests/integrations/litellm/cache/test_async_key.py b/labs/AgentStream/exgentic/tests/integrations/litellm/cache/test_async_key.py new file mode 100644 index 00000000..f79dcfcd --- /dev/null +++ b/labs/AgentStream/exgentic/tests/integrations/litellm/cache/test_async_key.py @@ -0,0 +1,49 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from typing import Any + +import pytest +from exgentic.integrations.litellm.cache import CustomCache + + +def _build_response() -> dict[str, Any]: + return { + "model": "gpt-4o-mini", + "choices": [ + { + "message": {"role": "assistant", "content": "ok"}, + "finish_reason": "stop", + } + ], + } + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +@pytest.mark.anyio +async def test_async_cache_key_reuse_for_call_id() -> None: + cache = CustomCache(type="local") + base_kwargs = { + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "hello"}], + "litellm_call_id": "call-1", + } + assert await cache.async_get_cache(**base_kwargs) is None + + mutated_kwargs = dict(base_kwargs) + mutated_kwargs["messages"] = [{"role": "user", "content": "different"}] + response = _build_response() + await cache.async_add_cache(response, **mutated_kwargs) + + followup_kwargs = { + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "hello"}], + "litellm_call_id": "call-2", + } + assert await cache.async_get_cache(**followup_kwargs) is not None diff --git a/labs/AgentStream/exgentic/tests/integrations/litellm/cache/test_cache_logger_context.py b/labs/AgentStream/exgentic/tests/integrations/litellm/cache/test_cache_logger_context.py new file mode 100644 index 00000000..c0fc7b82 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/integrations/litellm/cache/test_cache_logger_context.py @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from exgentic.core.context import Context, Role, set_context +from exgentic.integrations.litellm.cache.log import CacheLogger + + +def test_cache_logger_uses_context_session_path(tmp_path) -> None: + ctx = Context( + run_id="run-cache", + output_dir=str(tmp_path), + cache_dir=str(tmp_path / "cache"), + session_id="sess-1", + role=Role.AGENT, + ) + set_context(ctx) + + logger = CacheLogger(disk_cache_dir=str(tmp_path / "cache"), strip_time=False) + logger.hit() + + expected = tmp_path / "run-cache" / "sessions" / "sess-1" / "agent" / "litellm" / "cache.log" + assert expected.exists() diff --git a/labs/AgentStream/exgentic/tests/integrations/litellm/cache/test_key.py b/labs/AgentStream/exgentic/tests/integrations/litellm/cache/test_key.py new file mode 100644 index 00000000..4b2baa7d --- /dev/null +++ b/labs/AgentStream/exgentic/tests/integrations/litellm/cache/test_key.py @@ -0,0 +1,91 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from exgentic.integrations.litellm.cache import ( + CustomCache, + strip_date_time_from_text, +) + + +def test_strip_date_time_from_text_removes_date_time() -> None: + text = "Schedule 2024-05-01 at 09:30 AM for review." + cleaned = strip_date_time_from_text(text) + assert "2024-05-01" not in cleaned + assert "09:30" not in cleaned + + +def test_cache_key_ignores_date_time_when_enabled() -> None: + cache = CustomCache(type="local", delete_time_from_messages=True) + key_one = cache.get_cache_key( + model="gpt-4o-mini", + messages=[{"role": "user", "content": "Today is 2024-05-01 10:30."}], + ) + key_two = cache.get_cache_key( + model="gpt-4o-mini", + messages=[{"role": "user", "content": "Today is 2025-06-02 22:15."}], + ) + assert key_one == key_two + + +def test_cache_key_keeps_date_time_when_disabled() -> None: + cache = CustomCache(type="local", delete_time_from_messages=False) + key_one = cache.get_cache_key( + model="gpt-4o-mini", + messages=[{"role": "user", "content": "Today is 2024-05-01 10:30."}], + ) + key_two = cache.get_cache_key( + model="gpt-4o-mini", + messages=[{"role": "user", "content": "Today is 2025-06-02 22:15."}], + ) + assert key_one != key_two + + +def test_strip_date_does_not_remove_adjacent_numbers() -> None: + r"""Regression test: numbers following a date should not be stripped. + + The original regex pattern `(?:,?\s+\d{2,4})?` was too permissive, + matching 2-4 digit numbers after a space as if they were years. + + Example 1 - Broken datetime parsing: + Input: "Jan 20 15:36" + Bug: Date regex matches "Jan 20 15" (treats hour "15" as a year) + Result: Leaves ":36" orphaned, time regex fails (no word boundary before ":") + + Example 2 - Lost data: + Input: "May 15 30 items" + Bug: Date regex matches "May 15 30" (treats "30" as a year) + Result: Loses the number "30" entirely + + The fix uses `(?:,\s*\d{2}|\s+\d{4})?` which requires: + - Comma followed by exactly 2-digit year (e.g., "May 15,23") + - Space followed by exactly 4-digit year (e.g., "May 15 2023") + """ + # Example 1: Datetime parsing - hour mistaken for year + # Original bug: "Jan 20 15:36" -> matches "Jan 20 15", orphans ":36" + text = "Event at Jan 20 15:36 in the main hall" + cleaned = strip_date_time_from_text(text) + assert "Jan 20" not in cleaned + assert "15:36" not in cleaned # time should be stripped properly + assert ":36" not in cleaned # no orphaned time fragment + assert cleaned == "Event at in the main hall" + + # Example 2: Adjacent numbers - count mistaken for year + # Original bug: "May 15 30" -> stripped entirely, losing "30" + text2 = "May 15 30 items in stock" + cleaned2 = strip_date_time_from_text(text2) + assert "May 15" not in cleaned2 + assert "30" in cleaned2 + assert cleaned2 == "30 items in stock" + + # Example 3: 3-digit numbers are also not years + # Original bug: "May 15 300" -> stripped entirely + text3 = "May 15 300 users joined" + cleaned3 = strip_date_time_from_text(text3) + assert "300" in cleaned3 + assert cleaned3 == "300 users joined" + + # Valid year formats should still be stripped correctly + assert strip_date_time_from_text("May 15 2023 was great") == "was great" + assert strip_date_time_from_text("May 15, 23 ended") == "ended" diff --git a/labs/AgentStream/exgentic/tests/integrations/litellm/cache/test_settings.py b/labs/AgentStream/exgentic/tests/integrations/litellm/cache/test_settings.py new file mode 100644 index 00000000..57b6afc1 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/integrations/litellm/cache/test_settings.py @@ -0,0 +1,155 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import json +from pathlib import Path + +from exgentic.core.context import run_scope, try_get_context +from exgentic.core.types import RunConfig +from exgentic.integrations.litellm.cache import build_litellm_cache +from exgentic.integrations.litellm.config import configure_litellm +from exgentic.integrations.litellm.trace_logger import ( + FILE_ENV, + AsyncTraceLogger, + SyncTraceLogger, + TraceLogger, +) +from exgentic.utils.settings import ExgenticSettings, resolve_cache_path + + +def test_resolve_cache_path_uses_base_dir_for_relative_paths() -> None: + assert resolve_cache_path(".exgentic", ".litellm_cache") == ".exgentic/.litellm_cache" + + +def test_resolve_cache_path_keeps_absolute_paths() -> None: + assert resolve_cache_path(".exgentic", "/tmp/litellm") == "/tmp/litellm" + + +def test_build_litellm_cache_resolves_relative_path_under_cache_dir(tmp_path) -> None: + base_dir = tmp_path / "cache-root" + settings = ExgenticSettings( + cache_dir=str(base_dir), + litellm_cache_dir=".litellm_cache", + ) + cache = build_litellm_cache(settings) + assert cache.cache.disk_cache.directory == str(base_dir / ".litellm_cache") + + +def test_run_config_to_session_config_preserves_cache_dir() -> None: + run_config = RunConfig( + benchmark="tau2", + agent="tool_calling", + cache_dir="/tmp/exgentic-cache", + ) + session_config = run_config.to_session_config("task-1") + assert session_config.cache_dir == "/tmp/exgentic-cache" + + +def test_run_scope_sets_and_restores_cache_env() -> None: + before = try_get_context() + with run_scope( + run_id="cache-test", + output_dir="./outputs", + cache_dir="./cache", + ): + ctx = try_get_context() + assert ctx is not None + assert ctx.cache_dir == str(Path("./cache").resolve()) + after = try_get_context() + if before is None: + assert after is None + else: + assert after == before + + +def test_configure_litellm_always_registers_trace_logger_callbacks() -> None: + import litellm + + original_callbacks = litellm.callbacks + original_success = litellm.success_callback + original_failure = litellm.failure_callback + original_async_success = litellm._async_success_callback + original_async_failure = litellm._async_failure_callback + try: + litellm.callbacks = [] + litellm.success_callback = [] + litellm.failure_callback = [] + litellm._async_success_callback = [] + litellm._async_failure_callback = [] + + settings = ExgenticSettings(litellm_caching=False) + configure_litellm(config=settings.to_litellm_config(), cache_only=False) + configure_litellm(config=settings.to_litellm_config(), cache_only=False) + + assert any(isinstance(cb, SyncTraceLogger) for cb in litellm.success_callback) + assert any(isinstance(cb, AsyncTraceLogger) for cb in litellm.success_callback) + assert any(isinstance(cb, SyncTraceLogger) for cb in litellm.failure_callback) + assert any(isinstance(cb, AsyncTraceLogger) for cb in litellm.failure_callback) + assert sum(isinstance(cb, SyncTraceLogger) for cb in litellm.success_callback) == 1 + assert sum(isinstance(cb, AsyncTraceLogger) for cb in litellm.success_callback) == 1 + assert sum(isinstance(cb, SyncTraceLogger) for cb in litellm.failure_callback) == 1 + assert sum(isinstance(cb, AsyncTraceLogger) for cb in litellm.failure_callback) == 1 + assert any(isinstance(cb, SyncTraceLogger) for cb in litellm._async_success_callback) + assert any(isinstance(cb, AsyncTraceLogger) for cb in litellm._async_success_callback) + assert any(isinstance(cb, SyncTraceLogger) for cb in litellm._async_failure_callback) + assert any(isinstance(cb, AsyncTraceLogger) for cb in litellm._async_failure_callback) + finally: + litellm.callbacks = original_callbacks + litellm.success_callback = original_success + litellm.failure_callback = original_failure + litellm._async_success_callback = original_async_success + litellm._async_failure_callback = original_async_failure + + +def test_trace_logger_callback_registered_and_writes_by_default(tmp_path, monkeypatch) -> None: + import litellm + + original_callbacks = litellm.callbacks + original_success = litellm.success_callback + original_failure = litellm.failure_callback + original_async_success = litellm._async_success_callback + original_async_failure = litellm._async_failure_callback + try: + litellm.callbacks = [] + litellm.success_callback = [] + litellm.failure_callback = [] + litellm._async_success_callback = [] + litellm._async_failure_callback = [] + + log_path = tmp_path / "trace.jsonl" + monkeypatch.setenv(FILE_ENV, str(log_path)) + + settings = ExgenticSettings(litellm_caching=False) + configure_litellm(config=settings.to_litellm_config(), cache_only=False) + + registered = [cb for cb in litellm.success_callback if isinstance(cb, TraceLogger)] + assert len(registered) == 2 + + kwargs = { + "model": "openai/gpt-4o-mini", + "messages": [{"role": "user", "content": "Hi"}], + "response_cost": 0.0, + } + response = { + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + "choices": [ + { + "message": {"role": "assistant", "content": "ok"}, + "finish_reason": "stop", + } + ], + } + registered[0].log_success_event(kwargs, response, None, None) + + assert log_path.exists() + record = json.loads(log_path.read_text(encoding="utf-8").splitlines()[0]) + assert record["status"] == "success" + assert record["model"] == "openai/gpt-4o-mini" + finally: + litellm.callbacks = original_callbacks + litellm.success_callback = original_success + litellm.failure_callback = original_failure + litellm._async_success_callback = original_async_success + litellm._async_failure_callback = original_async_failure diff --git a/labs/AgentStream/exgentic/tests/integrations/litellm/cache/test_sync_key.py b/labs/AgentStream/exgentic/tests/integrations/litellm/cache/test_sync_key.py new file mode 100644 index 00000000..6da48f80 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/integrations/litellm/cache/test_sync_key.py @@ -0,0 +1,76 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from typing import Any + +from exgentic.integrations.litellm.cache import CustomCache + + +def _build_response() -> dict[str, Any]: + return { + "model": "gpt-4o-mini", + "choices": [ + { + "message": {"role": "assistant", "content": "ok"}, + "finish_reason": "stop", + } + ], + } + + +def test_sync_cache_key_reuse_for_call_id() -> None: + cache = CustomCache(type="local") + base_kwargs = { + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "hello"}], + "litellm_call_id": "call-1", + } + assert cache.get_cache(**base_kwargs) is None + + mutated_kwargs = dict(base_kwargs) + mutated_kwargs["messages"] = [{"role": "user", "content": "different"}] + cache.add_cache(_build_response(), **mutated_kwargs) + + followup_kwargs = { + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "hello"}], + "litellm_call_id": "call-2", + } + assert cache.get_cache(**followup_kwargs) is not None + + +def test_sync_cache_without_call_id_uses_computed_key() -> None: + cache = CustomCache(type="local") + kwargs = { + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "hello"}], + } + assert cache.get_cache(**kwargs) is None + cache.add_cache(_build_response(), **kwargs) + assert cache.get_cache(**kwargs) is not None + + +def test_sync_cache_key_reuse_scoped_to_call_id() -> None: + cache = CustomCache(type="local") + base_kwargs = { + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "hello"}], + "litellm_call_id": "call-1", + } + assert cache.get_cache(**base_kwargs) is None + + mutated_kwargs = { + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "different"}], + "litellm_call_id": "call-2", + } + cache.add_cache(_build_response(), **mutated_kwargs) + + followup_kwargs = { + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "hello"}], + "litellm_call_id": "call-3", + } + assert cache.get_cache(**followup_kwargs) is None diff --git a/labs/AgentStream/exgentic/tests/integrations/litellm/proxy/conftest.py b/labs/AgentStream/exgentic/tests/integrations/litellm/proxy/conftest.py new file mode 100644 index 00000000..8bbe5a1c --- /dev/null +++ b/labs/AgentStream/exgentic/tests/integrations/litellm/proxy/conftest.py @@ -0,0 +1,77 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any + +import pytest + + +class FakeOpenAIHandler(BaseHTTPRequestHandler): + request_count = 0 + + def do_GET(self) -> None: # noqa: N802 + if self.path.endswith("/v1/models"): + payload = {"object": "list", "data": [{"id": "openai/gpt-4o-mini"}]} + self._write_json(payload) + return + self.send_error(404) + + def do_POST(self) -> None: # noqa: N802 + if self.path.endswith("/v1/chat/completions"): + length = int(self.headers.get("content-length", "0")) + body = self.rfile.read(length).decode("utf-8") if length else "{}" + data = json.loads(body or "{}") + FakeOpenAIHandler.request_count += 1 + model = data.get("model", "openai/gpt-4o-mini") + payload = { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 0, + "model": model, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "ok"}, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + } + self._write_json(payload) + return + self.send_error(404) + + def log_message(self, fmt: str, *args: Any) -> None: + return + + def _write_json(self, payload: dict[str, Any]) -> None: + data = json.dumps(payload).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + +@pytest.fixture() +def fake_openai_server() -> ThreadingHTTPServer: + try: + server = ThreadingHTTPServer(("127.0.0.1", 0), FakeOpenAIHandler) + except PermissionError as exc: + pytest.skip(f"Socket binding not permitted: {exc}") + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server + finally: + server.shutdown() + server.server_close() diff --git a/labs/AgentStream/exgentic/tests/integrations/litellm/proxy/test_proxy_cache_execution.py b/labs/AgentStream/exgentic/tests/integrations/litellm/proxy/test_proxy_cache_execution.py new file mode 100644 index 00000000..77e81488 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/integrations/litellm/proxy/test_proxy_cache_execution.py @@ -0,0 +1,51 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import pytest +import requests +from exgentic.integrations.litellm import LitellmProxy + + +@pytest.mark.skipif( + not (Path(sys.executable).parent / "litellm").exists(), + reason="litellm CLI not installed in active venv", +) +def test_proxy_reuses_cached_completion_response(tmp_path, fake_openai_server) -> None: + fake_openai_server.RequestHandlerClass.request_count = 0 + backend_port = fake_openai_server.server_address[1] + backend_base = f"http://127.0.0.1:{backend_port}/v1" + + venv_bin = Path(sys.executable).parent + env = { + "OPENAI_API_BASE": backend_base, + "OPENAI_API_KEY": "test-key", # pragma: allowlist secret + "EXGENTIC_CACHE_DIR": str(tmp_path / "cache"), + "EXGENTIC_litellm_cache_dir": ".litellm_cache", + "EXGENTIC_litellm_caching": "true", + "EXGENTIC_LOG_LEVEL": "DEBUG", + "PATH": f"{venv_bin}{os.pathsep}{os.environ.get('PATH', '')}", + } + payload = { + "model": "openai/gpt-4o-mini", + "messages": [{"role": "user", "content": "Hello"}], + "temperature": 0.0, + } + + with LitellmProxy( + model="openai/gpt-4o-mini", + env=env, + startup_timeout=10.0, + ) as proxy: + url = f"{proxy.base_url}/v1/chat/completions" + first = requests.post(url, json=payload, timeout=10) + first.raise_for_status() + second = requests.post(url, json=payload, timeout=10) + second.raise_for_status() + + assert fake_openai_server.RequestHandlerClass.request_count == 1 diff --git a/labs/AgentStream/exgentic/tests/integrations/litellm/proxy/test_proxy_callback_config.py b/labs/AgentStream/exgentic/tests/integrations/litellm/proxy/test_proxy_callback_config.py new file mode 100644 index 00000000..312b62ae --- /dev/null +++ b/labs/AgentStream/exgentic/tests/integrations/litellm/proxy/test_proxy_callback_config.py @@ -0,0 +1,54 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import json + +from exgentic.integrations.litellm import LitellmProxy + + +def test_proxy_writes_exgentic_trace_callback_to_litellm_settings_config(tmp_path, monkeypatch) -> None: + import exgentic.integrations.litellm.proxy as proxy_mod + + class _DummyProc: + def poll(self): + return None + + def terminate(self) -> None: + return None + + def wait(self, timeout=None) -> int: + return 0 + + def kill(self) -> None: + return None + + def _fake_popen(*args, **kwargs): + return _DummyProc() + + monkeypatch.setattr(proxy_mod.subprocess, "Popen", _fake_popen) + monkeypatch.setattr(proxy_mod, "_is_port_open", lambda *_args, **_kwargs: True) + monkeypatch.setattr(proxy_mod, "_is_proxy_ready", lambda *_args, **_kwargs: True) + + log_path = tmp_path / "litellm.log" + + with LitellmProxy( + model="openai/gpt-4o-mini", + port=49999, + log_path=str(log_path), + startup_timeout=1.0, + ): + config_path = log_path.with_name("litellm_config.json") + assert config_path.exists() + config_data = json.loads(config_path.read_text(encoding="utf-8")) + callback = "exgentic.integrations.litellm.trace_logger.trace_logger" + async_callback = "exgentic.integrations.litellm.trace_logger.async_trace_logger" + assert set(config_data["litellm_settings"]["success_callback"]) == { + callback, + async_callback, + } + assert set(config_data["litellm_settings"]["failure_callback"]) == { + callback, + async_callback, + } diff --git a/labs/AgentStream/exgentic/tests/integrations/litellm/proxy/test_proxy_callback_execution.py b/labs/AgentStream/exgentic/tests/integrations/litellm/proxy/test_proxy_callback_execution.py new file mode 100644 index 00000000..ca11c700 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/integrations/litellm/proxy/test_proxy_callback_execution.py @@ -0,0 +1,157 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import json +import os +import socket +import subprocess +import sys +import time +from pathlib import Path + +import pytest +import requests +from exgentic.integrations.litellm import LitellmProxy + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return int(s.getsockname()[1]) + + +def _wait_for_proxy(url: str, timeout: float = 10.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + try: + if requests.get(url, timeout=1.0).status_code == 200: + return + except Exception: + pass + time.sleep(0.1) + raise RuntimeError("LiteLLM proxy did not become ready") + + +@pytest.mark.skipif( + not (Path(sys.executable).parent / "litellm").exists(), + reason="litellm CLI not installed in active venv", +) +def test_exgentic_proxy_executes_default_trace_callback_and_writes_trace(tmp_path, fake_openai_server) -> None: + backend_port = fake_openai_server.server_address[1] + backend_base = f"http://127.0.0.1:{backend_port}/v1" + trace_path = tmp_path / "trace.jsonl" + + env = os.environ.copy() + env.update( + { + "OPENAI_API_BASE": backend_base, + "OPENAI_API_KEY": "test-key", # pragma: allowlist secret + "EXGENTIC_LLM_LOG_FILE": str(trace_path), + } + ) + + with LitellmProxy( + model="openai/gpt-4o-mini", + env=env, + startup_timeout=10.0, + ) as proxy: + response = requests.post( + f"{proxy.base_url}/v1/chat/completions", + json={ + "model": "openai/gpt-4o-mini", + "messages": [{"role": "user", "content": "Hello"}], + }, + timeout=10, + ) + response.raise_for_status() + + assert trace_path.exists() + rows = [json.loads(line) for line in trace_path.read_text().splitlines() if line] + assert rows and rows[0]["status"] == "success" + + +@pytest.mark.skipif( + not (Path(sys.executable).parent / "litellm").exists(), + reason="litellm CLI not installed in active venv", +) +def test_raw_litellm_proxy_executes_litellm_settings_callbacks_and_writes_trace(tmp_path, fake_openai_server) -> None: + backend_port = fake_openai_server.server_address[1] + backend_base = f"http://127.0.0.1:{backend_port}/v1" + proxy_port = _free_port() + trace_path = tmp_path / "trace.jsonl" + config_path = tmp_path / "litellm_config.json" + + callback_path = "exgentic.integrations.litellm.trace_logger.trace_logger" + async_callback_path = "exgentic.integrations.litellm.trace_logger.async_trace_logger" + config_path.write_text( + json.dumps( + { + "model_list": [ + { + "model_name": "openai/gpt-4o-mini", + "litellm_params": {"model": "openai/gpt-4o-mini"}, + } + ], + "litellm_settings": { + "success_callback": [callback_path, async_callback_path], + "failure_callback": [callback_path, async_callback_path], + }, + } + ), + encoding="utf-8", + ) + + env = os.environ.copy() + env.update( + { + "OPENAI_API_BASE": backend_base, + "OPENAI_API_KEY": "test-key", # pragma: allowlist secret + "EXGENTIC_LLM_LOG_FILE": str(trace_path), + } + ) + repo_root = Path(__file__).resolve().parents[4] + src_path = repo_root / "src" + env["PYTHONPATH"] = os.pathsep.join( + [ + str(src_path), + str(repo_root), + env.get("PYTHONPATH", ""), + ] + ) + + proc = subprocess.Popen( + [ + str(Path(sys.executable).parent / "litellm"), + "--config", + str(config_path), + "--port", + str(proxy_port), + ], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=env, + ) + try: + _wait_for_proxy(f"http://127.0.0.1:{proxy_port}/v1/models") + response = requests.post( + f"http://127.0.0.1:{proxy_port}/v1/chat/completions", + json={ + "model": "openai/gpt-4o-mini", + "messages": [{"role": "user", "content": "Hello"}], + }, + timeout=10, + ) + response.raise_for_status() + finally: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + + assert trace_path.exists() + rows = [json.loads(line) for line in trace_path.read_text().splitlines() if line] + assert rows and rows[0]["status"] == "success" diff --git a/labs/AgentStream/exgentic/tests/integrations/litellm/proxy/test_proxy_env.py b/labs/AgentStream/exgentic/tests/integrations/litellm/proxy/test_proxy_env.py new file mode 100644 index 00000000..0d68e236 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/integrations/litellm/proxy/test_proxy_env.py @@ -0,0 +1,39 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from exgentic.core.context import Context, set_context +from exgentic.integrations.litellm import LitellmProxy + + +def test_proxy_passes_context_env(monkeypatch): + captured = {} + + class _DummyProc: + def poll(self): + return None + + def terminate(self) -> None: + return None + + def wait(self, timeout=None) -> int: + return 0 + + def kill(self) -> None: + return None + + def _fake_popen(*args, **kwargs): + captured["env"] = kwargs.get("env", {}) + return _DummyProc() + + monkeypatch.setattr("exgentic.integrations.litellm.proxy.subprocess.Popen", _fake_popen) + monkeypatch.setattr("exgentic.integrations.litellm.proxy._is_port_open", lambda *_a, **_k: True) + monkeypatch.setattr("exgentic.integrations.litellm.proxy._is_proxy_ready", lambda *_a, **_k: True) + + ctx = Context(run_id="run-proxy", output_dir="/tmp/out", cache_dir="/tmp/cache") + set_context(ctx) + + with LitellmProxy(model="openai/gpt-4o-mini", port=49998, startup_timeout=1.0): + env = captured["env"] + assert env["EXGENTIC_CTX_RUN_ID"] == "run-proxy" diff --git a/labs/AgentStream/exgentic/tests/integrations/litellm/proxy/test_proxy_subprocess_integration.py b/labs/AgentStream/exgentic/tests/integrations/litellm/proxy/test_proxy_subprocess_integration.py new file mode 100644 index 00000000..4bf58dcc --- /dev/null +++ b/labs/AgentStream/exgentic/tests/integrations/litellm/proxy/test_proxy_subprocess_integration.py @@ -0,0 +1,110 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Integration test for proxy subprocess config validation and trace detail. + +Complements test_proxy_callback_execution.py (which already exercises the full +subprocess path via LitellmProxy) by adding: + +- Config file validation: checks that the generated JSON config contains the + expected callback entries before any request is made. +- Explicit subprocess liveness assertions (proxy._proc is not None / running). +- Detailed trace field validation: model name, token counts, request/response + presence — fields not checked by the existing execution test. +""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + +import pytest +import requests +from exgentic.integrations.litellm import LitellmProxy + + +@pytest.mark.skipif( + not (Path(sys.executable).parent / "litellm").exists(), + reason="litellm CLI not installed in active venv", +) +def test_proxy_subprocess_writes_trace_end_to_end(tmp_path, fake_openai_server) -> None: + """Verify config file contents, subprocess liveness, and detailed trace fields. + + Adds assertions that go beyond test_proxy_callback_execution.py: + - Config JSON contains the expected success_callback entries. + - The subprocess process object is alive during the request. + - Trace entries include model, token counts, request/response, and timestamp. + """ + backend_port = fake_openai_server.server_address[1] + backend_base = f"http://127.0.0.1:{backend_port}/v1" + trace_path = tmp_path / "trace.jsonl" + log_path = tmp_path / "proxy.log" + + env = os.environ.copy() + env.update( + { + "OPENAI_API_BASE": backend_base, + "OPENAI_API_KEY": "test-key", # pragma: allowlist secret + "EXGENTIC_LLM_LOG_FILE": str(trace_path), + } + ) + + # Start proxy as actual subprocess (not mocked) + with LitellmProxy( + model="openai/gpt-4o-mini", + env=env, + log_path=str(log_path), + startup_timeout=15.0, + ) as proxy: + # Verify config was written with callbacks + config_path = log_path.with_name("litellm_config.json") + assert config_path.exists(), "Config file should be written" + + config_data = json.loads(config_path.read_text(encoding="utf-8")) + assert "litellm_settings" in config_data + assert "success_callback" in config_data["litellm_settings"] + + expected_callbacks = { + "exgentic.integrations.litellm.trace_logger.trace_logger", + "exgentic.integrations.litellm.trace_logger.async_trace_logger", + } + actual_callbacks = set(config_data["litellm_settings"]["success_callback"]) + assert actual_callbacks == expected_callbacks + + # Verify proxy is running as subprocess (not in-process) + assert proxy._proc is not None + assert proxy._proc.poll() is None + + # Send request through subprocess proxy + response = requests.post( + f"{proxy.base_url}/v1/chat/completions", + json={ + "model": "openai/gpt-4o-mini", + "messages": [{"role": "user", "content": "Test message"}], + }, + timeout=10, + ) + response.raise_for_status() + response_data = response.json() + + assert "choices" in response_data + assert len(response_data["choices"]) > 0 + assert response_data["choices"][0]["message"]["content"] == "ok" + + # Verify trace was written by subprocess + assert trace_path.exists(), f"Trace file should exist at {trace_path}" + + trace_lines = trace_path.read_text().strip().splitlines() + assert len(trace_lines) > 0, "Trace file should have at least one entry" + + trace_entry = json.loads(trace_lines[0]) + assert trace_entry["status"] == "success" + assert trace_entry["model"] == "openai/gpt-4o-mini" + assert trace_entry["prompt_tokens"] == 1 + assert trace_entry["completion_tokens"] == 1 + assert trace_entry["total_tokens"] == 2 + assert "request" in trace_entry + assert "response" in trace_entry + assert "timestamp" in trace_entry diff --git a/labs/AgentStream/exgentic/tests/integrations/litellm/proxy/test_trace_logger_env.py b/labs/AgentStream/exgentic/tests/integrations/litellm/proxy/test_trace_logger_env.py new file mode 100644 index 00000000..10ab2248 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/integrations/litellm/proxy/test_trace_logger_env.py @@ -0,0 +1,162 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import asyncio +import json + +from exgentic.core.context import Context, Role +from exgentic.integrations.litellm.trace_logger import ( + FILE_ENV, + TraceLogger, +) + + +def _sample_payload() -> tuple[dict, dict]: + kwargs = { + "model": "openai/gpt-4o-mini", + "messages": [{"role": "user", "content": "Hi"}], + "response_cost": 0.0, + } + response = { + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + "choices": [{"message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}], + } + return kwargs, response + + +def test_trace_logger_writes(tmp_path, monkeypatch) -> None: + log_path = tmp_path / "trace.jsonl" + monkeypatch.setenv(FILE_ENV, str(log_path)) + + logger = TraceLogger() + kwargs, response = _sample_payload() + logger.log_success_event(kwargs, response, None, None) + + assert log_path.exists() + lines = log_path.read_text().strip().splitlines() + assert len(lines) == 1 + record = json.loads(lines[0]) + assert record["status"] == "success" + assert record["model"] == "openai/gpt-4o-mini" + + +def test_trace_logger_writes_without_toggle(tmp_path, monkeypatch) -> None: + log_path = tmp_path / "trace.jsonl" + monkeypatch.setenv(FILE_ENV, str(log_path)) + + logger = TraceLogger() + kwargs, response = _sample_payload() + logger.log_success_event(kwargs, response, None, None) + + assert log_path.exists() + lines = log_path.read_text().strip().splitlines() + assert len(lines) == 1 + + +def test_trace_logger_reads_context_from_metadata(tmp_path, monkeypatch) -> None: + env_log_path = tmp_path / "env_trace.jsonl" + monkeypatch.setenv(FILE_ENV, str(env_log_path)) + + logger = TraceLogger() + kwargs, response = _sample_payload() + kwargs["litellm_metadata"] = { + "context": Context( + run_id="run_meta", + output_dir=str(tmp_path), + cache_dir=str(tmp_path / "cache"), + session_id="sess_meta", + role=Role.AGENT, + ) + } + logger.log_success_event(kwargs, response, None, None) + + expected = tmp_path / "run_meta" / "sessions" / "sess_meta" / "agent" / "litellm" / "trace.jsonl" + assert expected.exists() + assert not env_log_path.exists() + + +def test_trace_logger_reads_context_from_nested_metadata(tmp_path, monkeypatch) -> None: + env_log_path = tmp_path / "env_trace.jsonl" + monkeypatch.setenv(FILE_ENV, str(env_log_path)) + + logger = TraceLogger() + kwargs, response = _sample_payload() + kwargs["litellm_params"] = { + "litellm_metadata": { + "context": Context( + run_id="run_nested", + output_dir=str(tmp_path), + cache_dir=str(tmp_path / "cache"), + session_id="sess_nested", + role=Role.AGENT, + ) + } + } + logger.log_success_event(kwargs, response, None, None) + + expected = tmp_path / "run_nested" / "sessions" / "sess_nested" / "agent" / "litellm" / "trace.jsonl" + assert expected.exists() + assert not env_log_path.exists() + + +def test_trace_logger_async_writes_with_kwargs_context(tmp_path, monkeypatch) -> None: + env_log_path = tmp_path / "env_trace.jsonl" + monkeypatch.setenv(FILE_ENV, str(env_log_path)) + + logger = TraceLogger() + kwargs, response = _sample_payload() + kwargs["litellm_metadata"] = { + "context": Context( + run_id="run_async", + output_dir=str(tmp_path), + cache_dir=str(tmp_path / "cache"), + session_id="sess_async", + role=Role.AGENT, + ) + } + + asyncio.run(logger.async_log_success_event(kwargs, response, None, None)) + + expected = tmp_path / "run_async" / "sessions" / "sess_async" / "agent" / "litellm" / "trace.jsonl" + assert expected.exists() + lines = expected.read_text().strip().splitlines() + assert len(lines) == 1 + record = json.loads(lines[0]) + assert record["status"] == "success" + assert not env_log_path.exists() + + +def test_trace_logger_uses_kwargs_context_for_log_path(tmp_path, monkeypatch) -> None: + env_log_path = tmp_path / "env_trace.jsonl" + monkeypatch.setenv(FILE_ENV, str(env_log_path)) + + logger = TraceLogger() + kwargs, response = _sample_payload() + kwargs["context"] = Context( + run_id="run_abc", + output_dir=str(tmp_path), + cache_dir=str(tmp_path / "cache"), + session_id="sess_123", + role=Role.AGENT, + ) + logger.log_success_event(kwargs, response, None, None) + + expected = tmp_path / "run_abc" / "sessions" / "sess_123" / "agent" / "litellm" / "trace.jsonl" + assert expected.exists() + assert not env_log_path.exists() + + +def test_trace_logger_get_context_falls_back_to_try_get_context(monkeypatch) -> None: + fallback = Context( + run_id="run_fallback", + output_dir="/tmp/out", + cache_dir="/tmp/cache", + session_id="sess_fallback", + role=Role.AGENT, + ) + monkeypatch.setattr("exgentic.core.context.try_get_context", lambda: fallback) + + logger = TraceLogger() + assert logger.get_context({}) == fallback diff --git a/labs/AgentStream/exgentic/tests/integrations/litellm/test_health.py b/labs/AgentStream/exgentic/tests/integrations/litellm/test_health.py new file mode 100644 index 00000000..aa4a9566 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/integrations/litellm/test_health.py @@ -0,0 +1,85 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Tests for LiteLLM health check error handling.""" + +from __future__ import annotations + +import logging +from unittest.mock import patch + +import pytest +from exgentic.integrations.litellm.health import check_model_accessible_sync + + +class MockLiteLLMError(Exception): + """Mock exception that mimics LiteLLM exceptions with .message attribute.""" + + def __init__(self, message: str): + self.message = message + super().__init__() + + def __str__(self) -> str: + """Return empty string to simulate LiteLLM exceptions that don't implement __str__.""" + return "" + + +def test_health_check_extracts_message_attribute_from_exception(caplog): + """Test that health check extracts error details from exception.message attribute.""" + caplog.set_level(logging.ERROR) + + with patch("exgentic.utils.sync.run_sync") as mock_run_sync: + exc = MockLiteLLMError("API key authentication failed") + mock_run_sync.side_effect = exc + + logger = logging.getLogger("test") + + with pytest.raises(RuntimeError) as exc_info: + check_model_accessible_sync("test-model", logger) + + error_msg = str(exc_info.value) + assert "API key authentication failed" in error_msg + assert "test-model" in error_msg + assert error_msg == "Model test-model is not accessible: API key authentication failed" + + +def test_health_check_falls_back_to_str_when_no_message_attribute(caplog): + """Test that health check falls back to str(exc) when .message is not available.""" + caplog.set_level(logging.ERROR) + + with patch("exgentic.utils.sync.run_sync") as mock_run_sync: + exc = ValueError("Standard error message") + mock_run_sync.side_effect = exc + + logger = logging.getLogger("test") + + with pytest.raises(RuntimeError) as exc_info: + check_model_accessible_sync("test-model", logger) + + error_msg = str(exc_info.value) + assert "Standard error message" in error_msg + assert "test-model" in error_msg + + +def test_health_check_uses_repr_as_last_resort(caplog): + """Test that health check uses repr(exc) when both .message and str(exc) are empty.""" + caplog.set_level(logging.ERROR) + + class EmptyError(Exception): + """Exception that returns empty string from __str__.""" + + def __str__(self) -> str: + return "" + + with patch("exgentic.utils.sync.run_sync") as mock_run_sync: + exc = EmptyError("hidden") + mock_run_sync.side_effect = exc + + logger = logging.getLogger("test") + + with pytest.raises(RuntimeError) as exc_info: + check_model_accessible_sync("test-model", logger) + + error_msg = str(exc_info.value) + assert "EmptyError" in error_msg + assert "test-model" in error_msg diff --git a/labs/AgentStream/exgentic/tests/integrations/litellm/test_trace_cost.py b/labs/AgentStream/exgentic/tests/integrations/litellm/test_trace_cost.py new file mode 100644 index 00000000..850da09b --- /dev/null +++ b/labs/AgentStream/exgentic/tests/integrations/litellm/test_trace_cost.py @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import json + +from exgentic.integrations.litellm.trace_cost import load_trace_cost + + +def test_load_trace_cost_uses_explicit_cost_field(tmp_path, monkeypatch) -> None: + trace_path = tmp_path / "trace.jsonl" + trace_path.write_text( + "\n".join( + [ + json.dumps({"cost": 1.25, "prompt_tokens": 1, "completion_tokens": 2}), + "not-json", + json.dumps({"cost": "2.5"}), + ] + ), + encoding="utf-8", + ) + + def _should_not_be_called(**_kwargs): + raise AssertionError("token fallback should not be used when cost is explicit") + + import exgentic.integrations.litellm.trace_cost as trace_cost_mod + + monkeypatch.setattr(trace_cost_mod, "litellm_tokens_cost", _should_not_be_called) + + assert load_trace_cost(trace_path, "openai/gpt-4o-mini") == 3.75 + + +def test_load_trace_cost_falls_back_to_token_estimate(tmp_path, monkeypatch) -> None: + trace_path = tmp_path / "trace.jsonl" + trace_path.write_text( + "\n".join( + [ + json.dumps({"prompt_tokens": 10, "completion_tokens": 20}), + json.dumps({"cost": None, "prompt_tokens": 5, "completion_tokens": 5}), + ] + ), + encoding="utf-8", + ) + + class _Cost: + def __init__(self, total_cost: float) -> None: + self.total_cost = total_cost + + def _fake_token_cost(*, model_name: str, input_tokens: int, output_tokens: int): + assert model_name == "openai/gpt-4o-mini" + return _Cost(total_cost=(input_tokens + output_tokens) / 1000.0) + + import exgentic.integrations.litellm.trace_cost as trace_cost_mod + + monkeypatch.setattr(trace_cost_mod, "litellm_tokens_cost", _fake_token_cost) + + assert load_trace_cost(trace_path, "openai/gpt-4o-mini") == 0.04 diff --git a/labs/AgentStream/exgentic/tests/integrations/litellm/test_trace_logger_context.py b/labs/AgentStream/exgentic/tests/integrations/litellm/test_trace_logger_context.py new file mode 100644 index 00000000..25214bcf --- /dev/null +++ b/labs/AgentStream/exgentic/tests/integrations/litellm/test_trace_logger_context.py @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import os +from pathlib import Path + +from exgentic.core.context import ( + ENV_OTEL_SPAN_ID, + ENV_OTEL_TRACE_ID, + Context, + init_context_from_env, +) +from exgentic.integrations.litellm.trace_logger import TraceLogger + + +def test_trace_logger_initializes_context_from_env(tmp_path: Path): + ctx = Context(run_id="run-env", output_dir=str(tmp_path), cache_dir=str(tmp_path)) + env = ctx.to_env() + os.environ.update(env) + os.environ.pop(ENV_OTEL_TRACE_ID, None) + os.environ.pop(ENV_OTEL_SPAN_ID, None) + + # Force env init. + init_context_from_env() + logger = TraceLogger() + path = logger._resolve_log_path({}) + + assert "run-env" in path + + for k in env: + os.environ.pop(k, None) diff --git a/labs/AgentStream/exgentic/tests/integrations/test_mcp_agent.py b/labs/AgentStream/exgentic/tests/integrations/test_mcp_agent.py new file mode 100644 index 00000000..6cc18c78 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/integrations/test_mcp_agent.py @@ -0,0 +1,214 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import threading +import time +from typing import Literal + +import pytest +from exgentic.adapters.actions.functions import action_type_to_function +from exgentic.adapters.agents import mcp_agent as mcp +from exgentic.core.types import ( + ActionType, + MultiObservation, + ParallelAction, + SingleAction, + SingleObservation, +) +from pydantic import BaseModel + + +class FakeMCPServer: + def __init__(self, mcp=None, *args, **kwargs) -> None: + tools = kwargs.get("tools") or [] + if mcp is None: + mcp = FakeMCP() + for fn in tools: + mcp.tool(fn) + self.mcp = mcp + self.started = False + self.stopped = False + self.stop_calls = [] + self.host = "127.0.0.1" + self.connect_host = "127.0.0.1" + self.port = 12345 + + def start(self, timeout: float = 5.0) -> None: + self.started = True + + def stop( + self, + timeout: float = 10.0, + *, + error: BaseException | None = None, + raise_on_timeout: bool = True, + ) -> None: + self.stopped = True + self.stop_calls.append((timeout, error, raise_on_timeout)) + + def __enter__(self): + self.start() + return self + + def __exit__(self, exc_type, exc, tb): + self.stop(error=exc, raise_on_timeout=True) + + +class FakeMCP: + def __init__(self) -> None: + self.tools: list = [] + + def tool(self, fn): + self.tools.append(fn) + + +class DummyMCPAgent(mcp.MCPAgent): + def run_mcp_agent(self, mcp_host: str, mcp_port: int) -> str: + self._last_mcp = (mcp_host, mcp_port) + return "ok" + + +class ErrorMCPAgent(mcp.MCPAgent): + def run_mcp_agent(self, mcp_host: str, mcp_port: int) -> str: + raise ValueError("boom") + + +def _patch_mcp(monkeypatch): + monkeypatch.setattr(mcp, "MCPServer", FakeMCPServer) + + +@pytest.fixture +def env(tmp_path): + from exgentic.core.context import run_scope + + with run_scope(run_id="test_run", output_dir=str(tmp_path)): + yield tmp_path + + +def test_run_code_agent_success_stops_server(env, monkeypatch): + _patch_mcp(monkeypatch) + agent = DummyMCPAgent("session") + agent.task = "task" + agent.context = {} + agent.actions = [] + + result = agent.run_code_agent([lambda: None]) + + assert result == "ok" + assert isinstance(agent.mcp, FakeMCP) + assert agent.mcp.tools + assert agent._mcp_server is not None + assert agent._mcp_server.started is True + assert agent._mcp_server.stopped is True + + +def test_run_code_agent_propagates_error_and_cleans_up(env, monkeypatch): + _patch_mcp(monkeypatch) + agent = ErrorMCPAgent("session") + agent.task = "task" + agent.context = {} + agent.actions = [] + + with pytest.raises(ValueError, match="boom"): + agent.run_code_agent([lambda: None]) + + assert agent._mcp_server is not None + assert agent._mcp_server.started is True + assert agent._mcp_server.stopped is True + + +def test_run_code_agent_ping_timeout_still_cleans_up(env, monkeypatch): + _patch_mcp(monkeypatch) + + def _raise_timeout(self, timeout: float = 5.0) -> None: + raise TimeoutError("ping timeout") + + monkeypatch.setattr(FakeMCPServer, "start", _raise_timeout) + agent = DummyMCPAgent("session") + agent.task = "task" + agent.context = {} + agent.actions = [] + + with pytest.raises(TimeoutError, match="ping timeout"): + agent.run_code_agent([lambda: None]) + + assert agent._mcp_server is not None + assert agent._mcp_server.started is False + assert agent._mcp_server.stopped is False + + +class ToolArgs(BaseModel): + value: int + + +class ToolA(SingleAction): + name: Literal["tool.a"] = "tool.a" + arguments: ToolArgs + + +class ToolB(SingleAction): + name: Literal["tool.b"] = "tool.b" + arguments: ToolArgs + + +class ParallelToolMCPAgent(mcp.MCPAgent): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.results: list = [] + + def run_mcp_agent(self, mcp_host: str, mcp_port: int) -> str: + tools = list(self.mcp.tools) + results = [] + + def _call(fn, value): + results.append(fn(value=value)) + + t1 = threading.Thread(target=_call, args=(tools[0], 1)) + t2 = threading.Thread(target=_call, args=(tools[1], 2)) + t1.start() + t2.start() + t1.join(timeout=2.0) + t2.join(timeout=2.0) + if t1.is_alive() or t2.is_alive(): + raise RuntimeError("Tool calls did not complete") + self.results = results + return "ok" + + +def test_mcp_agent_parallel_tool_calls_return_parallel_action(env, monkeypatch): + _patch_mcp(monkeypatch) + + actions = [ + ActionType(name="tool.a", description="tool a", cls=ToolA), + ActionType(name="tool.b", description="tool b", cls=ToolB), + ] + agent = ParallelToolMCPAgent("session") + agent.task = "task" + agent.context = {} + agent.actions = actions + functions = [action_type_to_function(act, agent.execute) for act in actions] + + worker = threading.Thread(target=agent.run_code_agent, args=(functions,)) + worker.start() + + deadline = time.time() + 1.0 + with agent._condition: + while len(agent._pending_actions) < 2 and time.time() < deadline: + agent._condition.wait(timeout=0.05) + pending = list(agent._pending_actions) + + observations = [ + SingleObservation(invoking_actions=[action], result={"ok": action.arguments.value}) + for action in pending + if isinstance(action, SingleAction) + ] + act = agent.react(MultiObservation(observations=observations)) + assert isinstance(act, ParallelAction) + worker.join(timeout=1.0) + if worker.is_alive(): + agent.close() + pytest.fail("MCP agent did not finish after parallel tool calls") + agent.close() + + assert len(agent.results) == 2 + assert all(isinstance(r, dict) and r.get("ok") in (1, 2) for r in agent.results) diff --git a/labs/AgentStream/exgentic/tests/integrations/test_mcp_server.py b/labs/AgentStream/exgentic/tests/integrations/test_mcp_server.py new file mode 100644 index 00000000..e6dcbe39 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/integrations/test_mcp_server.py @@ -0,0 +1,342 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import logging +import time +from datetime import timedelta + +import httpx +import pytest +from exgentic.adapters.agents import mcp_server as mcp_srv +from exgentic.utils.sync import run_sync +from mcp.client.session import ClientSession +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.exceptions import McpError + + +class FakeMCP: + def __init__(self, *args, **kwargs) -> None: + fake_ts = type( + "FakeTransportSecurity", + (), + {"allowed_hosts": [], "allowed_origins": []}, + )() + self.settings = type( + "FakeSettings", + (), + {"streamable_http_path": "/mcp", "transport_security": fake_ts}, + )() + return + + def add_tool(self, fn, **kwargs) -> None: + return None + + def streamable_http_app(self): + return object() + + +class FakeConfig: + def __init__(self, app, host, port, log_config=None) -> None: + self.app = app + self.host = host + self.port = port + self.log_config = log_config + + +class FakeServer: + def __init__(self, config) -> None: + self.config = config + self.should_exit = False + + def run(self, *args, **kwargs) -> None: + while not self.should_exit: + time.sleep(0.01) + + +def _patch_uvicorn(monkeypatch): + monkeypatch.setattr(mcp_srv.uvicorn, "Config", FakeConfig) + monkeypatch.setattr(mcp_srv.uvicorn, "Server", lambda cfg: FakeServer(cfg)) + monkeypatch.setattr(mcp_srv, "FastMCP", FakeMCP) + monkeypatch.setattr(mcp_srv, "wait_for_tcp", lambda *args, **kwargs: None) + + def _noop_run_sync(coro, timeout=None): + coro.close() + return + + monkeypatch.setattr(mcp_srv, "run_sync", _noop_run_sync) + monkeypatch.setattr(mcp_srv.socket, "socket", _fake_socket_factory()) + + +def _fake_socket_factory(): + class FakeSocket: + _next_port = 10000 + + def __init__(self, *args, **kwargs) -> None: + self._port = None + + def setsockopt(self, *args, **kwargs) -> None: + return None + + def bind(self, addr) -> None: + _, port = addr + if port == 0: + FakeSocket._next_port += 1 + port = FakeSocket._next_port + self._port = port + + def listen(self, backlog) -> None: + return None + + def getsockname(self): + return ("127.0.0.1", self._port) + + def close(self) -> None: + return None + + return FakeSocket + + +def test_mcp_server_start_stop(tmp_path, monkeypatch): + _patch_uvicorn(monkeypatch) + logger = logging.getLogger("test.mcp_server") + server = mcp_srv.MCPServer( + FakeMCP(), + host="127.0.0.1", + port=12345, + log_dir=tmp_path, + logger=logger, + ) + + server.start(timeout=1.0) + assert server.server is not None + assert server.thread is not None and server.thread.is_alive() + + server.stop(timeout=1.0) + assert server.server.should_exit is True + assert server.thread is None or not server.thread.is_alive() + + server.stop(timeout=1.0) + + +def test_mcp_server_start_stop_idempotent(tmp_path, monkeypatch): + _patch_uvicorn(monkeypatch) + logger = logging.getLogger("test.mcp_server.idempotent") + server = mcp_srv.MCPServer( + FakeMCP(), + log_dir=tmp_path, + logger=logger, + ) + + server.start(timeout=1.0) + first_thread = server.thread + server.start(timeout=1.0) + assert server.thread is first_thread + + server.stop(timeout=1.0) + server.stop(timeout=1.0) + + +def test_mcp_server_start_tcp_failure_stops(tmp_path, monkeypatch): + _patch_uvicorn(monkeypatch) + logger = logging.getLogger("test.mcp_server.tcp_fail") + server = mcp_srv.MCPServer( + FakeMCP(), + log_dir=tmp_path, + logger=logger, + ) + + def _raise_tcp(*args, **kwargs): + raise TimeoutError("tcp timeout") + + monkeypatch.setattr(mcp_srv, "wait_for_tcp", _raise_tcp) + + with pytest.raises(TimeoutError, match="tcp timeout"): + server.start(timeout=1.0) + + assert server.thread is None or not server.thread.is_alive() + + +def test_mcp_server_start_ping_failure_stops(tmp_path, monkeypatch): + _patch_uvicorn(monkeypatch) + logger = logging.getLogger("test.mcp_server.ping_fail") + server = mcp_srv.MCPServer( + FakeMCP(), + log_dir=tmp_path, + logger=logger, + ) + + def _raise_timeout(coro, timeout=None): + coro.close() + raise TimeoutError("ping timeout") + + monkeypatch.setattr(mcp_srv, "run_sync", _raise_timeout) + + with pytest.raises(TimeoutError, match="ping timeout"): + server.start(timeout=1.0) + + assert server.thread is None or not server.thread.is_alive() + + +def test_mcp_server_allocates_unique_ports(tmp_path, monkeypatch): + _patch_uvicorn(monkeypatch) + logger = logging.getLogger("test.mcp_server.ports") + + ports = set() + servers = [] + for _ in range(50): + server = mcp_srv.MCPServer( + FakeMCP(), + log_dir=tmp_path, + logger=logger, + ) + servers.append(server) + server.start(timeout=1.0) + ports.add(server.port) + + assert len(ports) == len(servers) + for server in servers: + server.stop(timeout=1.0) + + +def test_mcp_server_start_timeout(tmp_path, monkeypatch): + _patch_uvicorn(monkeypatch) + logger = logging.getLogger("test.mcp_server.timeout") + server = mcp_srv.MCPServer( + FakeMCP(), + host="127.0.0.1", + port=12345, + log_dir=tmp_path, + logger=logger, + ) + + def _no_start(): + time.sleep(0.2) + + monkeypatch.setattr(server, "_thread_entry", _no_start) + + with pytest.raises(RuntimeError, match="did not signal startup"): + server.start(timeout=0.05) + + if server.thread and server.thread.is_alive(): + server.thread.join(timeout=0.5) + + +async def _call_tool(app, name: str, arguments: dict, read_timeout_seconds=None): + async with app.router.lifespan_context(app): + transport = httpx.ASGITransport(app=app) + base_url = "http://127.0.0.1:9999" + async with httpx.AsyncClient( + transport=transport, + base_url=base_url, + ) as client: + async with streamable_http_client( + f"{base_url}/mcp", + http_client=client, + ) as (read_stream, write_stream, _): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + return await session.call_tool( + name, + arguments=arguments, + read_timeout_seconds=read_timeout_seconds, + ) + + +def test_mcp_server_unknown_tool_returns_mcp_error(tmp_path): + logger = logging.getLogger("test.mcp_server.unknown_tool") + + def echo(text: str) -> str: + return text + + server = mcp_srv.MCPServer( + tools=[echo], + log_dir=tmp_path, + logger=logger, + ) + app = server.mcp.streamable_http_app() + + result = run_sync(_call_tool(app, "does_not_exist", {})) + assert result.isError is True + assert any(block.text == "Unknown tool: does_not_exist" for block in result.content) + + +def test_mcp_server_invalid_tool_argument_name(tmp_path): + logger = logging.getLogger("test.mcp_server.bad_arg_name") + + def echo(text: str) -> str: + return text + + server = mcp_srv.MCPServer( + tools=[echo], + log_dir=tmp_path, + logger=logger, + ) + app = server.mcp.streamable_http_app() + + result = run_sync(_call_tool(app, "echo", {"wrong": "hi"})) + assert result.isError is True + assert any( + "Error executing tool echo:" in block.text and "validation error" in block.text.lower() + for block in result.content + ) + + +def test_mcp_server_invalid_tool_argument_type(tmp_path): + logger = logging.getLogger("test.mcp_server.bad_arg_type") + + def echo(text: str) -> str: + return text + + server = mcp_srv.MCPServer( + tools=[echo], + log_dir=tmp_path, + logger=logger, + ) + app = server.mcp.streamable_http_app() + + result = run_sync(_call_tool(app, "echo", {"text": 123})) + assert result.isError is True + assert any( + "Error executing tool echo:" in block.text and "validation error" in block.text.lower() + for block in result.content + ) + + +def test_mcp_server_call_tool_timeout(tmp_path): + logger = logging.getLogger("test.mcp_server.call_timeout") + + def slow(text: str) -> str: + time.sleep(0.2) + return text + + server = mcp_srv.MCPServer( + tools=[slow], + log_dir=tmp_path, + logger=logger, + ) + app = server.mcp.streamable_http_app() + + timeout = timedelta(milliseconds=10) + exc: BaseException | None = None + try: + run_sync(_call_tool(app, "slow", {"text": "hi"}, timeout)) + except BaseExceptionGroup as err: + exc = err + except McpError as err: + exc = err + + assert exc is not None + + def _find_mcp_error(err: BaseException) -> McpError | None: + if isinstance(err, McpError): + return err + if isinstance(err, BaseExceptionGroup): + for sub in err.exceptions: + found = _find_mcp_error(sub) + if found is not None: + return found + return None + + mcp_error = _find_mcp_error(exc) + assert mcp_error is not None + assert "Timed out while waiting for response" in str(mcp_error) diff --git a/labs/AgentStream/exgentic/tests/setup/__init__.py b/labs/AgentStream/exgentic/tests/setup/__init__.py new file mode 100644 index 00000000..5769a11d --- /dev/null +++ b/labs/AgentStream/exgentic/tests/setup/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Tests for the ``exgentic setup`` flow under tool-install-like isolation.""" diff --git a/labs/AgentStream/exgentic/tests/setup/test_tool_install.py b/labs/AgentStream/exgentic/tests/setup/test_tool_install.py new file mode 100644 index 00000000..58df2e65 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/setup/test_tool_install.py @@ -0,0 +1,103 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +"""Integration test that simulates ``uv tool install exgentic``. + +The test creates an isolated venv (the way ``uv tool install`` would), +installs exgentic into it, then runs ``exgentic setup --benchmark `` +from a clean working directory that has **no** ``pyproject.toml`` in any +parent — exactly the situation a user hits after a global tool install. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +_uv_available = shutil.which("uv") is not None + +# Root of the exgentic source tree (two levels up from this file). +_REPO_ROOT = Path(__file__).resolve().parents[2] + +# Per-benchmark setup timeout in seconds (5 minutes). +_SETUP_TIMEOUT = 300 + + +_ALL_BENCHMARKS = [ + "tau2", + "gsm8k", + "appworld", + "bfcl", + "browsecompplus", + "hotpotqa", + "swebench", +] + + +@pytest.mark.skipif(not _uv_available, reason="uv CLI not available") +@pytest.mark.parametrize("benchmark", _ALL_BENCHMARKS) +def test_setup_in_tool_install_venv(benchmark: str, tmp_path: Path) -> None: + """Install exgentic into a fresh venv, then run ``exgentic setup``.""" + venv_dir = tmp_path / "venv" + work_dir = tmp_path / "workdir" + work_dir.mkdir() + + # 1. Create an isolated venv using uv. + subprocess.run( + ["uv", "venv", str(venv_dir), "--python", f"{sys.version_info.major}.{sys.version_info.minor}"], + check=True, + capture_output=True, + timeout=60, + ) + + # 2. Install exgentic from the local source tree into the venv. + subprocess.run( + ["uv", "pip", "install", str(_REPO_ROOT), "--python", str(venv_dir / "bin" / "python")], + check=True, + capture_output=True, + timeout=300, + ) + + # 3. Locate the ``exgentic`` entry-point inside the venv. + exgentic_bin = venv_dir / "bin" / "exgentic" + assert exgentic_bin.exists(), f"exgentic CLI not found at {exgentic_bin}" + + # 4. Run ``exgentic setup --benchmark `` from the clean workdir. + # The working directory deliberately has no pyproject.toml ancestors. + result = subprocess.run( + [str(exgentic_bin), "setup", "--benchmark", benchmark, "--force"], + cwd=str(work_dir), + capture_output=True, + text=True, + timeout=_SETUP_TIMEOUT, + ) + + assert result.returncode == 0, ( + f"exgentic setup --benchmark {benchmark} failed " + f"(rc={result.returncode}).\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + + # 5. Verify the installation marker was written. + venv_python = str(venv_dir / "bin" / "python") + check = subprocess.run( + [ + venv_python, + "-c", + ( + "from exgentic.environment.instance import get_manager; " + f"assert get_manager().is_installed('benchmarks/{benchmark}'), " + f"'installation marker not found for {benchmark}'" + ), + ], + cwd=str(work_dir), + capture_output=True, + text=True, + timeout=30, + ) + assert check.returncode == 0, ( + f"Installation marker check failed for {benchmark}.\n" f"stdout:\n{check.stdout}\nstderr:\n{check.stderr}" + ) diff --git a/labs/AgentStream/exgentic/tests/test_coordinator.py b/labs/AgentStream/exgentic/tests/test_coordinator.py new file mode 100644 index 00000000..2fb7fe3b --- /dev/null +++ b/labs/AgentStream/exgentic/tests/test_coordinator.py @@ -0,0 +1,298 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import threading +import time + +from exgentic.adapters.agents.coordinator import AgentCoordinator, CoordinatedAgent +from exgentic.core.types import ( + MultiObservation, + ParallelAction, + SingleAction, + SingleObservation, +) +from pydantic import BaseModel + + +class ScriptedAgent(CoordinatedAgent): + def __init__(self): + self.ready = threading.Event() + self.done = threading.Event() + self.seen = [] + + def run(self, adapter): + self.ready.set() + obs = adapter.get_observation() + self.seen.append(obs) + obs2 = adapter.execute("a0") + self.seen.append(obs2) + adapter.execute(None) + self.done.set() + + +def shutdown(coord: AgentCoordinator, timeout: float = 2.0): + coord.close() + if hasattr(coord, "join"): + coord.join(timeout=timeout) + + +def test_basic_handshake(): + internal = ScriptedAgent() + coord = AgentCoordinator("basic", internal) + coord.start(task="", context={}, actions=[]) + + assert internal.ready.wait(timeout=1.0) + + act = coord.react("obs0") + assert act == "a0" + + act = coord.react("obs1") + assert act is None + + shutdown(coord) + assert internal.done.wait(timeout=1.0) + + assert internal.seen[0] == "obs0" + assert len(internal.seen) == 2 + assert internal.seen[1] in ("obs1", None) + + +class TermAgent(CoordinatedAgent): + def __init__(self): + self.started = threading.Event() + self.done = threading.Event() + + def run(self, adapter): + self.started.set() + obs = adapter.get_observation() + if obs is None: + self.done.set() + return + adapter.execute("go") + obs2 = adapter.get_observation() + assert obs2 is None + adapter.execute(None) + self.done.set() + + +def test_terminal_observation_unblocks(): + internal = TermAgent() + coord = AgentCoordinator("term", internal) + coord.start(task="", context={}, actions=[]) + + assert internal.started.wait(timeout=1.0) + + act = coord.react("init") + assert act == "go" + + act = coord.react(None) + assert act is None + + shutdown(coord) + assert internal.done.wait(timeout=1.0) + + +def test_parallel_execute_threads_return_parallel_action(): + coord = _coordinator() + action_a = _action("tool.a", 1) + action_b = _action("tool.b", 2) + start = threading.Event() + + def _call(action): + start.wait() + coord.execute(action) + + t1 = threading.Thread(target=_call, args=(action_a,)) + t2 = threading.Thread(target=_call, args=(action_b,)) + t1.start() + t2.start() + start.set() + + deadline = time.time() + 1.0 + with coord._condition: + while len(coord._pending_actions) < 2 and time.time() < deadline: + coord._condition.wait(timeout=0.05) + + act = coord.react("obs") + assert isinstance(act, ParallelAction) + assert {a.id for a in act.actions} == {action_a.id, action_b.id} + + coord.close() + t1.join(timeout=1.0) + t2.join(timeout=1.0) + + +def test_no_accumulation_without_window(): + coord = _coordinator() + action_a = _action("tool.a", 1) + action_b = _action("tool.b", 2) + start = threading.Event() + + def _call(action, delay=0.0): + start.wait() + if delay: + time.sleep(delay) + coord.execute(action) + + t1 = threading.Thread(target=_call, args=(action_a,)) + t2 = threading.Thread(target=_call, args=(action_b, 0.5)) + t1.start() + t2.start() + start.set() + + deadline = time.time() + 1.0 + with coord._condition: + while len(coord._pending_actions) < 1 and time.time() < deadline: + coord._condition.wait(timeout=0.05) + + act = coord.react("obs") + assert isinstance(act, SingleAction) + assert act.id == action_a.id + + act2 = coord.react("obs2") + assert isinstance(act2, SingleAction) + assert act2.id == action_b.id + + coord.close() + t1.join(timeout=1.0) + t2.join(timeout=1.0) + + +def test_accumulation_window_batches_actions(): + coord = _coordinator(accumulate_window_seconds=0.3) + action_a = _action("tool.a", 1) + action_b = _action("tool.b", 2) + start = threading.Event() + + def _call(action, delay=0.0): + start.wait() + if delay: + time.sleep(delay) + coord.execute(action) + + t1 = threading.Thread(target=_call, args=(action_a,)) + t2 = threading.Thread(target=_call, args=(action_b, 0.1)) + t1.start() + t2.start() + start.set() + + deadline = time.time() + 1.0 + with coord._condition: + while len(coord._pending_actions) < 1 and time.time() < deadline: + coord._condition.wait(timeout=0.05) + + act = coord.react("obs") + assert isinstance(act, ParallelAction) + assert {a.id for a in act.actions} == {action_a.id, action_b.id} + + coord.close() + t1.join(timeout=1.0) + t2.join(timeout=1.0) + + +def test_execute_returns_observation_for_action(): + coord = _coordinator() + action_a = _action("tool.a", 1) + action_b = _action("tool.b", 2) + start = threading.Event() + results = {} + + def _call(action): + start.wait() + results[action.name] = coord.execute(action) + + t1 = threading.Thread(target=_call, args=(action_a,)) + t2 = threading.Thread(target=_call, args=(action_b,)) + t1.start() + t2.start() + start.set() + + deadline = time.time() + 1.0 + with coord._condition: + while len(coord._pending_actions) < 2 and time.time() < deadline: + coord._condition.wait(timeout=0.05) + + act = coord.react( + MultiObservation( + observations=[ + SingleObservation(invoking_actions=[action_a], result="ra"), + SingleObservation(invoking_actions=[action_b], result="rb"), + ] + ) + ) + assert isinstance(act, ParallelAction) + + t1.join(timeout=1.0) + t2.join(timeout=1.0) + coord.close() + + assert results["tool.a"].result == "ra" + assert results["tool.b"].result == "rb" + + +class DummyArgs(BaseModel): + value: int + + +def _action(name: str, value: int) -> SingleAction: + return SingleAction(name=name, arguments=DummyArgs(value=value)) + + +def _coordinator(accumulate_window_seconds: float | None = None) -> AgentCoordinator: + class NoopAgent(CoordinatedAgent): + def run(self, adapter): + return None + + return AgentCoordinator( + "rewire", + NoopAgent(), + accumulate_window_seconds=accumulate_window_seconds, + ) + + +def test_rewire_observation_assigns_by_order(): + coord = _coordinator() + action_a = _action("tool.a", 1) + action_b = _action("tool.b", 2) + coord._last_actions = [action_a, action_b] + + observation = MultiObservation( + observations=[ + SingleObservation(result="ra"), + SingleObservation(result="rb"), + ] + ) + rewired = coord._rewire_observation(observation) + + assert rewired.observations[0].invoking_actions == [action_a] + assert rewired.observations[1].invoking_actions == [action_b] + + +def test_rewire_single_observation_attaches_all_actions(): + coord = _coordinator() + action_a = _action("tool.a", 1) + action_b = _action("tool.b", 2) + coord._last_actions = [action_a, action_b] + + observation = SingleObservation(result="combined") + rewired = coord._rewire_observation(observation) + + assert rewired.invoking_actions == [action_a, action_b] + + +def test_rewire_preserves_existing_invoking_actions(): + coord = _coordinator() + action_a = _action("tool.a", 1) + action_b = _action("tool.b", 2) + coord._last_actions = [action_a, action_b] + + observation = MultiObservation( + observations=[ + SingleObservation(result="rb", invoking_actions=[action_b]), + SingleObservation(result="ra"), + ] + ) + rewired = coord._rewire_observation(observation) + + assert rewired.observations[0].invoking_actions == [action_b] + assert rewired.observations[1].invoking_actions == [action_a] diff --git a/labs/AgentStream/exgentic/tests/test_env_loading.py b/labs/AgentStream/exgentic/tests/test_env_loading.py new file mode 100644 index 00000000..db11c9d6 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/test_env_loading.py @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +import importlib +import os +import sys +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + + +def test_dotenv_loads_settings_and_env_vars() -> None: + with TemporaryDirectory() as tmpdir: + env_path = Path(tmpdir) / ".env" + env_path.write_text( + "EXGENTIC_LOG_LEVEL=DEBUG\nWATSONX_API_KEY=abc123\n" # pragma: allowlist secret + ) + + with patch.dict(os.environ, {"EXGENTIC_DOTENV_PATH": str(env_path)}, clear=False): + os.environ.pop("EXGENTIC_LOG_LEVEL", None) + os.environ.pop("WATSONX_API_KEY", None) + + sys.modules.pop("exgentic.utils.settings", None) + settings_module = importlib.import_module("exgentic.utils.settings") + + settings_module.get_settings.cache_clear() + settings = settings_module.get_settings() + + assert settings.log_level == "DEBUG" + assert os.environ.get("WATSONX_API_KEY") == "abc123" + assert settings.dotenv_path == str(env_path) diff --git a/labs/AgentStream/exgentic/tests/test_integrations_functions.py b/labs/AgentStream/exgentic/tests/test_integrations_functions.py new file mode 100644 index 00000000..dc65847e --- /dev/null +++ b/labs/AgentStream/exgentic/tests/test_integrations_functions.py @@ -0,0 +1,91 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import pytest +from exgentic.adapters.actions.functions import action_type_to_function, bind_arguments +from exgentic.core.types import Action, ActionType, SingleAction, SingleObservation +from pydantic import BaseModel + + +def test_bind(): + class Args(BaseModel): + arg1: int + arg2: int + arg3: int + arg4: int + + # normal flow + expected = {"arg1": 1, "arg2": 2, "arg3": 3, "arg4": 4} + res = bind_arguments(cls=Args, args=[1, 2], kwargs={"arg3": 3, "arg4": 4}) + assert res == expected + + # missing positional arguments + expected = {"arg1": 1, "arg3": 3, "arg4": 4} + res = bind_arguments(cls=Args, args=[1], kwargs={"arg3": 3, "arg4": 4}) + assert res == expected + + # no positional arguments + expected = {"arg3": 3, "arg4": 4} + res = bind_arguments(cls=Args, args=[], kwargs={"arg3": 3, "arg4": 4}) + assert res == expected + + # missing keyword arguments + expected = {"arg1": 1, "arg2": 2, "arg4": 4} + res = bind_arguments(cls=Args, args=[1, 2], kwargs={"arg4": 4}) + assert res == expected + + # no keyword arguments + expected = {"arg1": 1, "arg2": 2} + res = bind_arguments(cls=Args, args=[1, 2], kwargs={}) + assert res == expected + + # duplicates + with pytest.raises(TypeError): + bind_arguments(cls=Args, args=[1, 2], kwargs={"arg2": 2, "arg3": 3}) + + # too many args + with pytest.raises(TypeError): + bind_arguments(cls=Args, args=[1, 2, 3, 4, 5], kwargs={}) + + +def test_action_type_to_function(): + def internal_function(action: Action): + return SingleObservation(result=action) + + class MyArgs(BaseModel): + arg1: int + arg2: int + arg3: int = 0 + arg4: int + + class MyAction(SingleAction): + name: str = "my_action" + arguments: MyArgs + + action_type = ActionType(name="my_action", description="my description", cls=MyAction) + + function = action_type_to_function(action_type, internal_function) + + expected_arguments = MyArgs(arg1=1, arg2=2, arg3=3, arg4=4) + action = function(1, 2, 3, 4) + assert action.arguments == expected_arguments + + expected_arguments = MyArgs(arg1=1, arg2=2, arg3=3, arg4=4) + action = function(1, 2, 3, arg4=4) + assert action.arguments == expected_arguments + + expected_arguments = MyArgs(arg1=1, arg2=2, arg4=4) + action = function(1, 2, arg4=4) + assert action.arguments == expected_arguments + + """invalid cases allows only by model_construct""" + # # missing required argument + # with pytest.raises(ValidationError): + # action = function(1,arg4=4) + + # # non existing argname + # with pytest.raises(ValidationError): + # action = function(1,2,3,arg4=4,arg5=5) + + # with pytest.raises(ValidationError): + # action = function(1,2,3, arg5=5) diff --git a/labs/AgentStream/exgentic/tests/utils/test_cost.py b/labs/AgentStream/exgentic/tests/utils/test_cost.py new file mode 100644 index 00000000..03e4340c --- /dev/null +++ b/labs/AgentStream/exgentic/tests/utils/test_cost.py @@ -0,0 +1,87 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +import os +import sys + +import pytest + +# Make package importable +sys.path.insert(0, os.path.abspath("src")) +from exgentic.utils.cost import TokensCost, litellm_tokens_cost # noqa: E402 + +# All models mentioned across examples and scripts +EXAMPLE_MODELS = [ + "watsonx/meta-llama/llama-3-3-70b-instruct", + "watsonx/meta-llama/llama-3-2-90b-vision-instruct", + "watsonx/openai/gpt-oss-120b", + "openai/Azure/gpt-4.1", + "openrouter/openai/gpt-oss-120b", +] + + +@pytest.mark.parametrize( + "name,expected", + [ + ( + "watsonx/meta-llama/llama-3-3-70b-instruct", + TokensCost( + input_cost=7.099999999999999e-05, + output_cost=7.099999999999999e-05, + total_cost=0.00014199999999999998, + ), + ), + ( + "watsonx/meta-llama/llama-3-2-90b-vision-instruct", + TokensCost( + input_cost=0.00019999999999999998, + output_cost=0.00019999999999999998, + total_cost=0.00039999999999999996, + ), + ), + ( + "watsonx/openai/gpt-oss-120b", + TokensCost( + input_cost=1.4999999999999999e-05, + output_cost=5.9999999999999995e-05, + total_cost=7.5e-05, + ), + ), + ( + "openai/Azure/gpt-4.1", + TokensCost( + input_cost=0.00019999999999999998, + output_cost=0.0007999999999999999, + total_cost=0.001, + ), + ), + ( + "openai/GCP/gemini-2.5-flash", + TokensCost( + input_cost=2.9999999999999997e-05, + output_cost=0.00025, + total_cost=0.00028, + ), + ), + ( + "openai/GCP/claude-haiku-4-5-20251001", + TokensCost( + input_cost=9.999999999999999e-05, + output_cost=0.0005, + total_cost=0.0006000000000000001, + ), + ), + ( + "openrouter/openai/gpt-oss-120b", + TokensCost( + input_cost=1.8e-05, + output_cost=7.999999999999999e-05, + total_cost=9.8e-05, + ), + ), + ], +) +def test_cost_per_token(name, expected): + cost = litellm_tokens_cost(model_name=name, input_tokens=100, output_tokens=100) + print(name, cost) + assert cost == expected diff --git a/labs/AgentStream/exgentic/tests/utils/test_litellm_cache_settings.py b/labs/AgentStream/exgentic/tests/utils/test_litellm_cache_settings.py new file mode 100644 index 00000000..458c83b1 --- /dev/null +++ b/labs/AgentStream/exgentic/tests/utils/test_litellm_cache_settings.py @@ -0,0 +1,51 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2026, The Exgentic organization and its contributors. + +from __future__ import annotations + +from exgentic.core.context import run_scope, try_get_context +from exgentic.core.types import RunConfig +from exgentic.integrations.litellm.cache_utils import build_litellm_cache +from exgentic.utils.settings import ExgenticSettings, resolve_cache_path + + +def test_resolve_cache_path_uses_base_dir_for_relative_paths() -> None: + assert resolve_cache_path(".exgentic", ".litellm_cache") == ".exgentic/.litellm_cache" + + +def test_resolve_cache_path_keeps_absolute_paths() -> None: + assert resolve_cache_path(".exgentic", "/tmp/litellm") == "/tmp/litellm" + + +def test_build_litellm_cache_resolves_relative_path_under_cache_dir(tmp_path) -> None: + base_dir = tmp_path / "cache-root" + settings = ExgenticSettings( + cache_dir=str(base_dir), + litellm_cache_dir=".litellm_cache", + ) + cache = build_litellm_cache(settings) + assert cache.cache.disk_cache.directory == str(base_dir / ".litellm_cache") + + +def test_run_config_to_session_config_preserves_cache_dir() -> None: + run_config = RunConfig( + benchmark="gsm8k", + agent="tool_calling", + cache_dir="/tmp/exgentic-cache", + ) + session_config = run_config.to_session_config("task-1") + assert session_config.cache_dir == "/tmp/exgentic-cache" + + +def test_run_context_sets_and_restores_cache_env(monkeypatch) -> None: + monkeypatch.delenv("EXGENTIC_CTX_CACHE_DIR", raising=False) + with run_scope( + run_id="cache-test", + output_dir="./outputs", + cache_dir="./cache", + ): + ctx = try_get_context() + assert ctx is not None + # Context resolves relative paths to absolute. + assert ctx.cache_dir.endswith("/cache") + assert not ctx.cache_dir.startswith(".") diff --git a/labs/AgentStream/exgentic/uv.lock b/labs/AgentStream/exgentic/uv.lock new file mode 100644 index 00000000..6e34b1a5 --- /dev/null +++ b/labs/AgentStream/exgentic/uv.lock @@ -0,0 +1,4102 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12.4' and python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version >= '3.12.4' and python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version >= '3.12.4' and python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.12.4' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.12.4' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.12.4' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] + +[manifest] +overrides = [{ name = "rich", specifier = ">=13.9.4,<14" }] + +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" }, + { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" }, + { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" }, + { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" }, + { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" }, + { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" }, + { url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" }, + { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, + { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, + { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, + { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, + { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, + { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, + { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, + { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, + { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, + { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, + { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, + { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, + { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, + { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, + { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, + { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, + { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, + { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, + { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, + { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, + { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, + { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, + { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, + { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, + { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, + { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, + { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, + { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, + { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[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.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "bidict" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/026678aa5a830e07cd9498a05d3e7e650a4f56a42f267a53d22bcda1bdc9/bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", size = 29093, upload-time = "2024-02-18T19:09:05.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" }, +] + +[[package]] +name = "certifi" +version = "2025.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, +] + +[[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/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { 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 = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, +] + +[[package]] +name = "cloudpickle" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, +] + +[[package]] +name = "codespell" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/e0/709453393c0ea77d007d907dd436b3ee262e28b30995ea1aa36c6ffbccaf/codespell-2.4.1.tar.gz", hash = "sha256:299fcdcb09d23e81e35a671bbe746d5ad7e8385972e65dbb833a2eaac33c01e5", size = 344740, upload-time = "2025-01-28T18:52:39.411Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/01/b394922252051e97aab231d416c86da3d8a6d781eeadcdca1082867de64e/codespell-2.4.1-py3-none-any.whl", hash = "sha256:3dadafa67df7e4a3dbf51e0d7315061b80d265f9552ebd699b3dd6834b47e425", size = 344501, upload-time = "2025-01-28T18:52:37.057Z" }, +] + +[[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 = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, + { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, + { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, + { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, + { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, + { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, + { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, + { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, + { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, + { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, + { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, + { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, + { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, + { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, + { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/e60e46adab4362a682cf142c7dcb5bf79b782ab2199b0dcb81f55970807f/cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea", size = 3698132, upload-time = "2025-10-15T23:18:17.056Z" }, + { url = "https://files.pythonhosted.org/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", size = 4243992, upload-time = "2025-10-15T23:18:18.695Z" }, + { url = "https://files.pythonhosted.org/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", size = 4409944, upload-time = "2025-10-15T23:18:20.597Z" }, + { url = "https://files.pythonhosted.org/packages/99/55/181022996c4063fc0e7666a47049a1ca705abb9c8a13830f074edb347495/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", size = 4242957, upload-time = "2025-10-15T23:18:22.18Z" }, + { url = "https://files.pythonhosted.org/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", size = 4409447, upload-time = "2025-10-15T23:18:24.209Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528, upload-time = "2025-10-15T23:18:26.227Z" }, +] + +[[package]] +name = "cuda-bindings" +version = "13.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-pathfinder", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/a9/3a8241c6e19483ac1f1dcf5c10238205dcb8a6e9d0d4d4709240dff28ff4/cuda_bindings-13.2.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:721104c603f059780d287969be3d194a18d0cc3b713ed9049065a1107706759d", size = 5730273, upload-time = "2026-03-11T00:12:37.18Z" }, + { url = "https://files.pythonhosted.org/packages/e9/94/2748597f47bb1600cd466b20cab4159f1530a3a33fe7f70fee199b3abb9e/cuda_bindings-13.2.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1eba9504ac70667dd48313395fe05157518fd6371b532790e96fbb31bbb5a5e1", size = 6313924, upload-time = "2026-03-11T00:12:39.462Z" }, + { url = "https://files.pythonhosted.org/packages/52/c8/b2589d68acf7e3d63e2be330b84bc25712e97ed799affbca7edd7eae25d6/cuda_bindings-13.2.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e865447abfb83d6a98ad5130ed3c70b1fc295ae3eeee39fd07b4ddb0671b6788", size = 5722404, upload-time = "2026-03-11T00:12:44.041Z" }, + { url = "https://files.pythonhosted.org/packages/1f/92/f899f7bbb5617bb65ec52a6eac1e9a1447a86b916c4194f8a5001b8cde0c/cuda_bindings-13.2.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46d8776a55d6d5da9dd6e9858fba2efcda2abe6743871dee47dd06eb8cb6d955", size = 6320619, upload-time = "2026-03-11T00:12:45.939Z" }, + { url = "https://files.pythonhosted.org/packages/df/93/eef988860a3ca985f82c4f3174fc0cdd94e07331ba9a92e8e064c260337f/cuda_bindings-13.2.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6629ca2df6f795b784752409bcaedbd22a7a651b74b56a165ebc0c9dcbd504d0", size = 5614610, upload-time = "2026-03-11T00:12:50.337Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/6db3aba46864aee357ab2415135b3fe3da7e9f1fa0221fa2a86a5968099c/cuda_bindings-13.2.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7dca0da053d3b4cc4869eff49c61c03f3c5dbaa0bcd712317a358d5b8f3f385d", size = 6149914, upload-time = "2026-03-11T00:12:52.374Z" }, + { url = "https://files.pythonhosted.org/packages/c0/87/87a014f045b77c6de5c8527b0757fe644417b184e5367db977236a141602/cuda_bindings-13.2.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6464b30f46692d6c7f65d4a0e0450d81dd29de3afc1bb515653973d01c2cd6e", size = 5685673, upload-time = "2026-03-11T00:12:56.371Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5e/c0fe77a73aaefd3fff25ffaccaac69c5a63eafdf8b9a4c476626ef0ac703/cuda_bindings-13.2.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4af9f3e1be603fa12d5ad6cfca7844c9d230befa9792b5abdf7dd79979c3626", size = 6191386, upload-time = "2026-03-11T00:12:58.965Z" }, + { url = "https://files.pythonhosted.org/packages/5f/58/ed2c3b39c8dd5f96aa7a4abef0d47a73932c7a988e30f5fa428f00ed0da1/cuda_bindings-13.2.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df850a1ff8ce1b3385257b08e47b70e959932f5f432d0a4e46a355962b4e4771", size = 5507469, upload-time = "2026-03-11T00:13:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/0c941b112ceeb21439b05895eace78ca1aa2eaaf695c8521a068fd9b4c00/cuda_bindings-13.2.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8a16384c6494e5485f39314b0b4afb04bee48d49edb16d5d8593fd35bbd231b", size = 6059693, upload-time = "2026-03-11T00:13:06.003Z" }, +] + +[[package]] +name = "cuda-pathfinder" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/d0/c177e29701cf1d3008d7d2b16b5fc626592ce13bd535f8795c5f57187e0e/cuda_pathfinder-1.5.4-py3-none-any.whl", hash = "sha256:9563d3175ce1828531acf4b94e1c1c7d67208c347ca002493e2654878b26f4b7", size = 51657, upload-time = "2026-04-27T22:42:07.712Z" }, +] + +[[package]] +name = "cuda-toolkit" +version = "13.0.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b2/453099f5f3b698d7d0eab38916aac44c7f76229f451709e2eb9db6615dcd/cuda_toolkit-13.0.2-py2.py3-none-any.whl", hash = "sha256:b198824cf2f54003f50d64ada3a0f184b42ca0846c1c94192fa269ecd97a66eb", size = 2364, upload-time = "2025-12-19T23:24:07.328Z" }, +] + +[package.optional-dependencies] +cublas = [ + { name = "nvidia-cublas", marker = "sys_platform == 'linux'" }, +] +cudart = [ + { name = "nvidia-cuda-runtime", marker = "sys_platform == 'linux'" }, +] +cufft = [ + { name = "nvidia-cufft", marker = "sys_platform == 'linux'" }, +] +cufile = [ + { name = "nvidia-cufile", marker = "sys_platform == 'linux'" }, +] +cupti = [ + { name = "nvidia-cuda-cupti", marker = "sys_platform == 'linux'" }, +] +curand = [ + { name = "nvidia-curand", marker = "sys_platform == 'linux'" }, +] +cusolver = [ + { name = "nvidia-cusolver", marker = "sys_platform == 'linux'" }, +] +cusparse = [ + { name = "nvidia-cusparse", marker = "sys_platform == 'linux'" }, +] +nvjitlink = [ + { name = "nvidia-nvjitlink", marker = "sys_platform == 'linux'" }, +] +nvrtc = [ + { name = "nvidia-cuda-nvrtc", marker = "sys_platform == 'linux'" }, +] +nvtx = [ + { name = "nvidia-nvtx", marker = "sys_platform == 'linux'" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "detect-secrets" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/67/382a863fff94eae5a0cf05542179169a1c49a4c8784a9480621e2066ca7d/detect_secrets-1.5.0.tar.gz", hash = "sha256:6bb46dcc553c10df51475641bb30fd69d25645cc12339e46c824c1e0c388898a", size = 97351, upload-time = "2024-05-06T17:46:19.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/5e/4f5fe4b89fde1dc3ed0eb51bd4ce4c0bca406246673d370ea2ad0c58d747/detect_secrets-1.5.0-py3-none-any.whl", hash = "sha256:e24e7b9b5a35048c313e983f76c4bd09dad89f045ff059e354f9943bf45aa060", size = 120341, upload-time = "2024-05-06T17:46:16.628Z" }, +] + +[[package]] +name = "diskcache" +version = "5.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[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 = "docutils" +version = "0.22.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/02/111134bfeb6e6c7ac4c74594e39a59f6c0195dc4846afbeac3cba60f1927/docutils-0.22.3.tar.gz", hash = "sha256:21486ae730e4ca9f622677b1412b879af1791efcfba517e4c6f60be543fc8cdd", size = 2290153, upload-time = "2025-11-06T02:35:55.655Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/a8/c6a4b901d17399c77cd81fb001ce8961e9f5e04d3daf27e8925cb012e163/docutils-0.22.3-py3-none-any.whl", hash = "sha256:bd772e4aca73aff037958d44f2be5229ded4c09927fcf8690c577b66234d6ceb", size = 633032, upload-time = "2025-11-06T02:35:52.391Z" }, +] + +[[package]] +name = "exgentic" +version = "0.0.0+agentstream" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "cloudpickle" }, + { name = "diskcache" }, + { name = "filelock" }, + { name = "json-schema-to-pydantic" }, + { name = "litellm" }, + { name = "mcp" }, + { name = "nicegui" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "rich" }, + { name = "rich-click" }, + { name = "typing-extensions" }, +] + +[package.optional-dependencies] +amem = [ + { name = "scikit-learn" }, + { name = "sentence-transformers" }, +] +analysis = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "scipy" }, + { name = "statsmodels" }, +] +dev = [ + { name = "codespell" }, + { name = "detect-secrets" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "ruff" }, +] +otel = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, + { name = "opentelemetry-semantic-conventions-ai" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.1.7,<9" }, + { name = "cloudpickle", specifier = ">=3,<4" }, + { name = "codespell", marker = "extra == 'dev'", specifier = ">=2.0.0,<3" }, + { name = "detect-secrets", marker = "extra == 'dev'", specifier = ">=1.0.0,<2" }, + { name = "diskcache", specifier = ">=5,<6" }, + { name = "filelock", specifier = ">=3,<4" }, + { name = "json-schema-to-pydantic", specifier = ">=0.4,<1" }, + { name = "litellm", specifier = ">=1.65.0,!=1.82.7,!=1.82.8,<2" }, + { name = "matplotlib", marker = "extra == 'analysis'", specifier = ">=3,<4" }, + { name = "mcp", specifier = ">=1.24,<2" }, + { name = "nicegui", specifier = ">=3,<4" }, + { name = "numpy", marker = "extra == 'analysis'", specifier = ">=2,<3" }, + { name = "opentelemetry-api", marker = "extra == 'otel'", specifier = ">=1,<2" }, + { name = "opentelemetry-exporter-otlp-proto-grpc", marker = "extra == 'otel'", specifier = ">=1,<2" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'otel'", specifier = ">=1,<2" }, + { name = "opentelemetry-sdk", marker = "extra == 'otel'", specifier = ">=1,<2" }, + { name = "opentelemetry-semantic-conventions-ai", marker = "extra == 'otel'", specifier = ">=0.4.0,<1" }, + { name = "pandas", marker = "extra == 'analysis'", specifier = ">=3,<4" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.0.0,<5" }, + { name = "pydantic", specifier = ">=2.9.2,<3" }, + { name = "pydantic-settings", specifier = ">=2,<3" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0,<10" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0,<2" }, + { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.0.0,<4" }, + { name = "python-dotenv", specifier = ">=1,<2" }, + { name = "rich", specifier = ">=13,<14" }, + { name = "rich-click", specifier = ">=1,<2" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0,<1" }, + { name = "scikit-learn", marker = "extra == 'amem'", specifier = ">=1,<2" }, + { name = "scipy", marker = "extra == 'analysis'", specifier = ">=1,<2" }, + { name = "sentence-transformers", marker = "extra == 'amem'", specifier = ">=3,<5" }, + { name = "statsmodels", marker = "extra == 'analysis'", specifier = ">=0.14,<1" }, + { name = "typing-extensions", specifier = ">=4,<5" }, +] +provides-extras = ["amem", "analysis", "dev", "otel"] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=9.0.1,<10" }] + +[[package]] +name = "fastapi" +version = "0.122.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/de/3ee97a4f6ffef1fb70bf20561e4f88531633bb5045dc6cebc0f8471f764d/fastapi-0.122.0.tar.gz", hash = "sha256:cd9b5352031f93773228af8b4c443eedc2ac2aa74b27780387b853c3726fb94b", size = 346436, upload-time = "2025-11-24T19:17:47.95Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/93/aa8072af4ff37b795f6bbf43dcaf61115f40f49935c7dbb180c9afc3f421/fastapi-0.122.0-py3-none-any.whl", hash = "sha256:a456e8915dfc6c8914a50d9651133bd47ec96d331c5b44600baa635538a30d67", size = 110671, upload-time = "2025-11-24T19:17:45.96Z" }, +] + +[[package]] +name = "fastuuid" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/7d/d9daedf0f2ebcacd20d599928f8913e9d2aea1d56d2d355a93bfa2b611d7/fastuuid-0.14.0.tar.gz", hash = "sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26", size = 18232, upload-time = "2025-10-19T22:19:22.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/f3/12481bda4e5b6d3e698fbf525df4443cc7dce746f246b86b6fcb2fba1844/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:73946cb950c8caf65127d4e9a325e2b6be0442a224fd51ba3b6ac44e1912ce34", size = 516386, upload-time = "2025-10-19T22:42:40.176Z" }, + { url = "https://files.pythonhosted.org/packages/59/19/2fc58a1446e4d72b655648eb0879b04e88ed6fa70d474efcf550f640f6ec/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:12ac85024637586a5b69645e7ed986f7535106ed3013640a393a03e461740cb7", size = 264569, upload-time = "2025-10-19T22:25:50.977Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/3c74756e5b02c40cfcc8b1d8b5bac4edbd532b55917a6bcc9113550e99d1/fastuuid-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:05a8dde1f395e0c9b4be515b7a521403d1e8349443e7641761af07c7ad1624b1", size = 254366, upload-time = "2025-10-19T22:29:49.166Z" }, + { url = "https://files.pythonhosted.org/packages/52/96/d761da3fccfa84f0f353ce6e3eb8b7f76b3aa21fd25e1b00a19f9c80a063/fastuuid-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09378a05020e3e4883dfdab438926f31fea15fd17604908f3d39cbeb22a0b4dc", size = 278978, upload-time = "2025-10-19T22:35:41.306Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c2/f84c90167cc7765cb82b3ff7808057608b21c14a38531845d933a4637307/fastuuid-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbb0c4b15d66b435d2538f3827f05e44e2baafcc003dd7d8472dc67807ab8fd8", size = 279692, upload-time = "2025-10-19T22:25:36.997Z" }, + { url = "https://files.pythonhosted.org/packages/af/7b/4bacd03897b88c12348e7bd77943bac32ccf80ff98100598fcff74f75f2e/fastuuid-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cd5a7f648d4365b41dbf0e38fe8da4884e57bed4e77c83598e076ac0c93995e7", size = 303384, upload-time = "2025-10-19T22:29:46.578Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a2/584f2c29641df8bd810d00c1f21d408c12e9ad0c0dafdb8b7b29e5ddf787/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c0a94245afae4d7af8c43b3159d5e3934c53f47140be0be624b96acd672ceb73", size = 460921, upload-time = "2025-10-19T22:36:42.006Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/c6b77443bb7764c760e211002c8638c0c7cce11cb584927e723215ba1398/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:2b29e23c97e77c3a9514d70ce343571e469098ac7f5a269320a0f0b3e193ab36", size = 480575, upload-time = "2025-10-19T22:28:18.975Z" }, + { url = "https://files.pythonhosted.org/packages/5a/87/93f553111b33f9bb83145be12868c3c475bf8ea87c107063d01377cc0e8e/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1e690d48f923c253f28151b3a6b4e335f2b06bf669c68a02665bc150b7839e94", size = 452317, upload-time = "2025-10-19T22:25:32.75Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8c/a04d486ca55b5abb7eaa65b39df8d891b7b1635b22db2163734dc273579a/fastuuid-0.14.0-cp311-cp311-win32.whl", hash = "sha256:a6f46790d59ab38c6aa0e35c681c0484b50dc0acf9e2679c005d61e019313c24", size = 154804, upload-time = "2025-10-19T22:24:15.615Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b2/2d40bf00820de94b9280366a122cbaa60090c8cf59e89ac3938cf5d75895/fastuuid-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:e150eab56c95dc9e3fefc234a0eedb342fac433dacc273cd4d150a5b0871e1fa", size = 156099, upload-time = "2025-10-19T22:24:31.646Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/e78fcc5df65467f0d207661b7ef86c5b7ac62eea337c0c0fcedbeee6fb13/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a", size = 510164, upload-time = "2025-10-19T22:31:45.635Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b3/c846f933f22f581f558ee63f81f29fa924acd971ce903dab1a9b6701816e/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d", size = 261837, upload-time = "2025-10-19T22:38:38.53Z" }, + { url = "https://files.pythonhosted.org/packages/54/ea/682551030f8c4fa9a769d9825570ad28c0c71e30cf34020b85c1f7ee7382/fastuuid-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070", size = 251370, upload-time = "2025-10-19T22:40:26.07Z" }, + { url = "https://files.pythonhosted.org/packages/14/dd/5927f0a523d8e6a76b70968e6004966ee7df30322f5fc9b6cdfb0276646a/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796", size = 277766, upload-time = "2025-10-19T22:37:23.779Z" }, + { url = "https://files.pythonhosted.org/packages/16/6e/c0fb547eef61293153348f12e0f75a06abb322664b34a1573a7760501336/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09", size = 278105, upload-time = "2025-10-19T22:26:56.821Z" }, + { url = "https://files.pythonhosted.org/packages/2d/b1/b9c75e03b768f61cf2e84ee193dc18601aeaf89a4684b20f2f0e9f52b62c/fastuuid-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8", size = 301564, upload-time = "2025-10-19T22:30:31.604Z" }, + { url = "https://files.pythonhosted.org/packages/fc/fa/f7395fdac07c7a54f18f801744573707321ca0cee082e638e36452355a9d/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741", size = 459659, upload-time = "2025-10-19T22:31:32.341Z" }, + { url = "https://files.pythonhosted.org/packages/66/49/c9fd06a4a0b1f0f048aacb6599e7d96e5d6bc6fa680ed0d46bf111929d1b/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057", size = 478430, upload-time = "2025-10-19T22:26:22.962Z" }, + { url = "https://files.pythonhosted.org/packages/be/9c/909e8c95b494e8e140e8be6165d5fc3f61fdc46198c1554df7b3e1764471/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8", size = 450894, upload-time = "2025-10-19T22:27:01.647Z" }, + { url = "https://files.pythonhosted.org/packages/90/eb/d29d17521976e673c55ef7f210d4cdd72091a9ec6755d0fd4710d9b3c871/fastuuid-0.14.0-cp312-cp312-win32.whl", hash = "sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176", size = 154374, upload-time = "2025-10-19T22:29:19.879Z" }, + { url = "https://files.pythonhosted.org/packages/cc/fc/f5c799a6ea6d877faec0472d0b27c079b47c86b1cdc577720a5386483b36/fastuuid-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397", size = 156550, upload-time = "2025-10-19T22:27:49.658Z" }, + { url = "https://files.pythonhosted.org/packages/a5/83/ae12dd39b9a39b55d7f90abb8971f1a5f3c321fd72d5aa83f90dc67fe9ed/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77a09cb7427e7af74c594e409f7731a0cf887221de2f698e1ca0ebf0f3139021", size = 510720, upload-time = "2025-10-19T22:42:34.633Z" }, + { url = "https://files.pythonhosted.org/packages/53/b0/a4b03ff5d00f563cc7546b933c28cb3f2a07344b2aec5834e874f7d44143/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9bd57289daf7b153bfa3e8013446aa144ce5e8c825e9e366d455155ede5ea2dc", size = 262024, upload-time = "2025-10-19T22:30:25.482Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6d/64aee0a0f6a58eeabadd582e55d0d7d70258ffdd01d093b30c53d668303b/fastuuid-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ac60fc860cdf3c3f327374db87ab8e064c86566ca8c49d2e30df15eda1b0c2d5", size = 251679, upload-time = "2025-10-19T22:36:14.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/a7e9cda8369e4f7919d36552db9b2ae21db7915083bc6336f1b0082c8b2e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab32f74bd56565b186f036e33129da77db8be09178cd2f5206a5d4035fb2a23f", size = 277862, upload-time = "2025-10-19T22:36:23.302Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d3/8ce11827c783affffd5bd4d6378b28eb6cc6d2ddf41474006b8d62e7448e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e678459cf4addaedd9936bbb038e35b3f6b2061330fd8f2f6a1d80414c0f87", size = 278278, upload-time = "2025-10-19T22:29:43.809Z" }, + { url = "https://files.pythonhosted.org/packages/a2/51/680fb6352d0bbade04036da46264a8001f74b7484e2fd1f4da9e3db1c666/fastuuid-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e3cc56742f76cd25ecb98e4b82a25f978ccffba02e4bdce8aba857b6d85d87b", size = 301788, upload-time = "2025-10-19T22:36:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7c/2014b5785bd8ebdab04ec857635ebd84d5ee4950186a577db9eff0fb8ff6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cb9a030f609194b679e1660f7e32733b7a0f332d519c5d5a6a0a580991290022", size = 459819, upload-time = "2025-10-19T22:35:31.623Z" }, + { url = "https://files.pythonhosted.org/packages/01/d2/524d4ceeba9160e7a9bc2ea3e8f4ccf1ad78f3bde34090ca0c51f09a5e91/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:09098762aad4f8da3a888eb9ae01c84430c907a297b97166b8abc07b640f2995", size = 478546, upload-time = "2025-10-19T22:26:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/bc/17/354d04951ce114bf4afc78e27a18cfbd6ee319ab1829c2d5fb5e94063ac6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1383fff584fa249b16329a059c68ad45d030d5a4b70fb7c73a08d98fd53bcdab", size = 450921, upload-time = "2025-10-19T22:31:02.151Z" }, + { url = "https://files.pythonhosted.org/packages/fb/be/d7be8670151d16d88f15bb121c5b66cdb5ea6a0c2a362d0dcf30276ade53/fastuuid-0.14.0-cp313-cp313-win32.whl", hash = "sha256:a0809f8cc5731c066c909047f9a314d5f536c871a7a22e815cc4967c110ac9ad", size = 154559, upload-time = "2025-10-19T22:36:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/22/1d/5573ef3624ceb7abf4a46073d3554e37191c868abc3aecd5289a72f9810a/fastuuid-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0df14e92e7ad3276327631c9e7cec09e32572ce82089c55cb1bb8df71cf394ed", size = 156539, upload-time = "2025-10-19T22:33:35.898Z" }, + { url = "https://files.pythonhosted.org/packages/16/c9/8c7660d1fe3862e3f8acabd9be7fc9ad71eb270f1c65cce9a2b7a31329ab/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b852a870a61cfc26c884af205d502881a2e59cc07076b60ab4a951cc0c94d1ad", size = 510600, upload-time = "2025-10-19T22:43:44.17Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f4/a989c82f9a90d0ad995aa957b3e572ebef163c5299823b4027986f133dfb/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c7502d6f54cd08024c3ea9b3514e2d6f190feb2f46e6dbcd3747882264bb5f7b", size = 262069, upload-time = "2025-10-19T22:43:38.38Z" }, + { url = "https://files.pythonhosted.org/packages/da/6c/a1a24f73574ac995482b1326cf7ab41301af0fabaa3e37eeb6b3df00e6e2/fastuuid-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ca61b592120cf314cfd66e662a5b54a578c5a15b26305e1b8b618a6f22df714", size = 251543, upload-time = "2025-10-19T22:32:22.537Z" }, + { url = "https://files.pythonhosted.org/packages/1a/20/2a9b59185ba7a6c7b37808431477c2d739fcbdabbf63e00243e37bd6bf49/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa75b6657ec129d0abded3bec745e6f7ab642e6dba3a5272a68247e85f5f316f", size = 277798, upload-time = "2025-10-19T22:33:53.821Z" }, + { url = "https://files.pythonhosted.org/packages/ef/33/4105ca574f6ded0af6a797d39add041bcfb468a1255fbbe82fcb6f592da2/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8a0dfea3972200f72d4c7df02c8ac70bad1bb4c58d7e0ec1e6f341679073a7f", size = 278283, upload-time = "2025-10-19T22:29:02.812Z" }, + { url = "https://files.pythonhosted.org/packages/fe/8c/fca59f8e21c4deb013f574eae05723737ddb1d2937ce87cb2a5d20992dc3/fastuuid-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1bf539a7a95f35b419f9ad105d5a8a35036df35fdafae48fb2fd2e5f318f0d75", size = 301627, upload-time = "2025-10-19T22:35:54.985Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e2/f78c271b909c034d429218f2798ca4e89eeda7983f4257d7865976ddbb6c/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:9a133bf9cc78fdbd1179cb58a59ad0100aa32d8675508150f3658814aeefeaa4", size = 459778, upload-time = "2025-10-19T22:28:00.999Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f0/5ff209d865897667a2ff3e7a572267a9ced8f7313919f6d6043aed8b1caa/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_i686.whl", hash = "sha256:f54d5b36c56a2d5e1a31e73b950b28a0d83eb0c37b91d10408875a5a29494bad", size = 478605, upload-time = "2025-10-19T22:36:21.764Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c8/2ce1c78f983a2c4987ea865d9516dbdfb141a120fd3abb977ae6f02ba7ca/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:ec27778c6ca3393ef662e2762dba8af13f4ec1aaa32d08d77f71f2a70ae9feb8", size = 450837, upload-time = "2025-10-19T22:34:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/df/60/dad662ec9a33b4a5fe44f60699258da64172c39bd041da2994422cdc40fe/fastuuid-0.14.0-cp314-cp314-win32.whl", hash = "sha256:e23fc6a83f112de4be0cc1990e5b127c27663ae43f866353166f87df58e73d06", size = 154532, upload-time = "2025-10-19T22:35:18.217Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/da4db31001e854025ffd26bc9ba0740a9cbba2c3259695f7c5834908b336/fastuuid-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:df61342889d0f5e7a32f7284e55ef95103f2110fee433c2ae7c2c0956d76ac8a", size = 156457, upload-time = "2025-10-19T22:33:44.579Z" }, +] + +[[package]] +name = "filelock" +version = "3.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, +] + +[[package]] +name = "fonttools" +version = "4.62.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/08/7012b00a9a5874311b639c3920270c36ee0c445b69d9989a85e5c92ebcb0/fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d", size = 3580737, upload-time = "2026-03-13T13:54:25.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/39/23ff32561ec8d45a4d48578b4d241369d9270dc50926c017570e60893701/fonttools-4.62.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:40975849bac44fb0b9253d77420c6d8b523ac4dcdcefeff6e4d706838a5b80f7", size = 2871039, upload-time = "2026-03-13T13:52:33.127Z" }, + { url = "https://files.pythonhosted.org/packages/24/7f/66d3f8a9338a9b67fe6e1739f47e1cd5cee78bd3bc1206ef9b0b982289a5/fonttools-4.62.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9dde91633f77fa576879a0c76b1d89de373cae751a98ddf0109d54e173b40f14", size = 2416346, upload-time = "2026-03-13T13:52:35.676Z" }, + { url = "https://files.pythonhosted.org/packages/aa/53/5276ceba7bff95da7793a07c5284e1da901cf00341ce5e2f3273056c0cca/fonttools-4.62.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6acb4109f8bee00fec985c8c7afb02299e35e9c94b57287f3ea542f28bd0b0a7", size = 5100897, upload-time = "2026-03-13T13:52:38.102Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a1/40a5c4d8e28b0851d53a8eeeb46fbd73c325a2a9a165f290a5ed90e6c597/fonttools-4.62.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1c5c25671ce8805e0d080e2ffdeca7f1e86778c5cbfbeae86d7f866d8830517b", size = 5071078, upload-time = "2026-03-13T13:52:41.305Z" }, + { url = "https://files.pythonhosted.org/packages/e3/be/d378fca4c65ea1956fee6d90ace6e861776809cbbc5af22388a090c3c092/fonttools-4.62.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a5d8825e1140f04e6c99bb7d37a9e31c172f3bc208afbe02175339e699c710e1", size = 5076908, upload-time = "2026-03-13T13:52:44.122Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d9/ae6a1d0693a4185a84605679c8a1f719a55df87b9c6e8e817bfdd9ef5936/fonttools-4.62.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:268abb1cb221e66c014acc234e872b7870d8b5d4657a83a8f4205094c32d2416", size = 5202275, upload-time = "2026-03-13T13:52:46.591Z" }, + { url = "https://files.pythonhosted.org/packages/54/6c/af95d9c4efb15cabff22642b608342f2bd67137eea6107202d91b5b03184/fonttools-4.62.1-cp311-cp311-win32.whl", hash = "sha256:942b03094d7edbb99bdf1ae7e9090898cad7bf9030b3d21f33d7072dbcb51a53", size = 2293075, upload-time = "2026-03-13T13:52:48.711Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/bf54c5b3f2be34e1f143e6db838dfdc54f2ffa3e68c738934c82f3b2a08d/fonttools-4.62.1-cp311-cp311-win_amd64.whl", hash = "sha256:e8514f4924375f77084e81467e63238b095abda5107620f49421c368a6017ed2", size = 2344593, upload-time = "2026-03-13T13:52:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/47/d4/dbacced3953544b9a93088cc10ef2b596d348c983d5c67a404fa41ec51ba/fonttools-4.62.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974", size = 2870219, upload-time = "2026-03-13T13:52:53.664Z" }, + { url = "https://files.pythonhosted.org/packages/66/9e/a769c8e99b81e5a87ab7e5e7236684de4e96246aae17274e5347d11ebd78/fonttools-4.62.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9", size = 2414891, upload-time = "2026-03-13T13:52:56.493Z" }, + { url = "https://files.pythonhosted.org/packages/69/64/f19a9e3911968c37e1e620e14dfc5778299e1474f72f4e57c5ec771d9489/fonttools-4.62.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936", size = 5033197, upload-time = "2026-03-13T13:52:59.179Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8a/99c8b3c3888c5c474c08dbfd7c8899786de9604b727fcefb055b42c84bba/fonttools-4.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392", size = 4988768, upload-time = "2026-03-13T13:53:02.761Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c6/0f904540d3e6ab463c1243a0d803504826a11604c72dd58c2949796a1762/fonttools-4.62.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04", size = 4971512, upload-time = "2026-03-13T13:53:05.678Z" }, + { url = "https://files.pythonhosted.org/packages/29/0b/5cbef6588dc9bd6b5c9ad6a4d5a8ca384d0cea089da31711bbeb4f9654a6/fonttools-4.62.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d", size = 5122723, upload-time = "2026-03-13T13:53:08.662Z" }, + { url = "https://files.pythonhosted.org/packages/4a/47/b3a5342d381595ef439adec67848bed561ab7fdb1019fa522e82101b7d9c/fonttools-4.62.1-cp312-cp312-win32.whl", hash = "sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c", size = 2281278, upload-time = "2026-03-13T13:53:10.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/b1/0c2ab56a16f409c6c8a68816e6af707827ad5d629634691ff60a52879792/fonttools-4.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42", size = 2331414, upload-time = "2026-03-13T13:53:13.992Z" }, + { url = "https://files.pythonhosted.org/packages/3b/56/6f389de21c49555553d6a5aeed5ac9767631497ac836c4f076273d15bd72/fonttools-4.62.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79", size = 2865155, upload-time = "2026-03-13T13:53:16.132Z" }, + { url = "https://files.pythonhosted.org/packages/03/c5/0e3966edd5ec668d41dfe418787726752bc07e2f5fd8c8f208615e61fa89/fonttools-4.62.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe", size = 2412802, upload-time = "2026-03-13T13:53:18.878Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/e6ac4b44026de7786fe46e3bfa0c87e51d5d70a841054065d49cd62bb909/fonttools-4.62.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68", size = 5013926, upload-time = "2026-03-13T13:53:21.379Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/8b1e801939839d405f1f122e7d175cebe9aeb4e114f95bfc45e3152af9a7/fonttools-4.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1", size = 4964575, upload-time = "2026-03-13T13:53:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/46/76/7d051671e938b1881670528fec69cc4044315edd71a229c7fd712eaa5119/fonttools-4.62.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069", size = 4953693, upload-time = "2026-03-13T13:53:26.569Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ae/b41f8628ec0be3c1b934fc12b84f4576a5c646119db4d3bdd76a217c90b5/fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9", size = 5094920, upload-time = "2026-03-13T13:53:29.329Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/53a1e9469331a23dcc400970a27a4caa3d9f6edbf5baab0260285238b884/fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24", size = 2279928, upload-time = "2026-03-13T13:53:32.352Z" }, + { url = "https://files.pythonhosted.org/packages/38/60/35186529de1db3c01f5ad625bde07c1f576305eab6d86bbda4c58445f721/fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056", size = 2330514, upload-time = "2026-03-13T13:53:34.991Z" }, + { url = "https://files.pythonhosted.org/packages/36/f0/2888cdac391807d68d90dcb16ef858ddc1b5309bfc6966195a459dd326e2/fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca", size = 2864442, upload-time = "2026-03-13T13:53:37.509Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b2/e521803081f8dc35990816b82da6360fa668a21b44da4b53fc9e77efcd62/fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca", size = 2410901, upload-time = "2026-03-13T13:53:40.55Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/8c3511ff06e53110039358dbbdc1a65d72157a054638387aa2ada300a8b8/fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782", size = 4999608, upload-time = "2026-03-13T13:53:42.798Z" }, + { url = "https://files.pythonhosted.org/packages/28/63/cd0c3b26afe60995a5295f37c246a93d454023726c3261cfbb3559969bb9/fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae", size = 4912726, upload-time = "2026-03-13T13:53:45.405Z" }, + { url = "https://files.pythonhosted.org/packages/70/b9/ac677cb07c24c685cf34f64e140617d58789d67a3dd524164b63648c6114/fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7", size = 4951422, upload-time = "2026-03-13T13:53:48.326Z" }, + { url = "https://files.pythonhosted.org/packages/e6/10/11c08419a14b85b7ca9a9faca321accccc8842dd9e0b1c8a72908de05945/fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a", size = 5060979, upload-time = "2026-03-13T13:53:51.366Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3c/12eea4a4cf054e7ab058ed5ceada43b46809fce2bf319017c4d63ae55bb4/fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800", size = 2283733, upload-time = "2026-03-13T13:53:53.606Z" }, + { url = "https://files.pythonhosted.org/packages/6b/67/74b070029043186b5dd13462c958cb7c7f811be0d2e634309d9a1ffb1505/fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e", size = 2335663, upload-time = "2026-03-13T13:53:56.23Z" }, + { url = "https://files.pythonhosted.org/packages/42/c5/4d2ed3ca6e33617fc5624467da353337f06e7f637707478903c785bd8e20/fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82", size = 2947288, upload-time = "2026-03-13T13:53:59.397Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e9/7ab11ddfda48ed0f89b13380e5595ba572619c27077be0b2c447a63ff351/fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260", size = 2449023, upload-time = "2026-03-13T13:54:01.642Z" }, + { url = "https://files.pythonhosted.org/packages/b2/10/a800fa090b5e8819942e54e19b55fc7c21fe14a08757c3aa3ca8db358939/fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4", size = 5137599, upload-time = "2026-03-13T13:54:04.495Z" }, + { url = "https://files.pythonhosted.org/packages/37/dc/8ccd45033fffd74deb6912fa1ca524643f584b94c87a16036855b498a1ed/fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b", size = 4920933, upload-time = "2026-03-13T13:54:07.557Z" }, + { url = "https://files.pythonhosted.org/packages/99/eb/e618adefb839598d25ac8136cd577925d6c513dc0d931d93b8af956210f0/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87", size = 5016232, upload-time = "2026-03-13T13:54:10.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5f/9b5c9bfaa8ec82def8d8168c4f13615990d6ce5996fe52bd49bfb5e05134/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c", size = 5042987, upload-time = "2026-03-13T13:54:13.569Z" }, + { url = "https://files.pythonhosted.org/packages/90/aa/dfbbe24c6a6afc5c203d90cc0343e24bcbb09e76d67c4d6eef8c2558d7ba/fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a", size = 2348021, upload-time = "2026-03-13T13:54:16.98Z" }, + { url = "https://files.pythonhosted.org/packages/13/6f/ae9c4e4dd417948407b680855c2c7790efb52add6009aaecff1e3bc50e8e/fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e", size = 2414147, upload-time = "2026-03-13T13:54:19.416Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "fsspec" +version = "2025.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/7f/2747c0d332b9acfa75dc84447a066fdf812b5a6b8d30472b74d309bfe8cb/fsspec-2025.10.0.tar.gz", hash = "sha256:b6789427626f068f9a83ca4e8a3cc050850b6c0f71f99ddb4f542b8266a26a59", size = 309285, upload-time = "2025-10-30T14:58:44.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/02/a6b21098b1d5d6249b7c5ab69dde30108a71e4e819d4a9778f1de1d5b70d/fsspec-2025.10.0-py3-none-any.whl", hash = "sha256:7c7712353ae7d875407f97715f0e1ffcc21e33d5b24556cb1e090ae9409ec61d", size = 200966, upload-time = "2025-10-30T14:58:42.53Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.72.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, +] + +[[package]] +name = "grpcio" +version = "1.78.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/8a/3d098f35c143a89520e568e6539cc098fcd294495910e359889ce8741c84/grpcio-1.78.0.tar.gz", hash = "sha256:7382b95189546f375c174f53a5fa873cef91c4b8005faa05cc5b3beea9c4f1c5", size = 12852416, upload-time = "2026-02-06T09:57:18.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/c7/d0b780a29b0837bf4ca9580904dfb275c1fc321ded7897d620af7047ec57/grpcio-1.78.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2777b783f6c13b92bd7b716667452c329eefd646bfb3f2e9dabea2e05dbd34f6", size = 5951525, upload-time = "2026-02-06T09:55:01.989Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b1/96920bf2ee61df85a9503cb6f733fe711c0ff321a5a697d791b075673281/grpcio-1.78.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:9dca934f24c732750389ce49d638069c3892ad065df86cb465b3fa3012b70c9e", size = 11830418, upload-time = "2026-02-06T09:55:04.462Z" }, + { url = "https://files.pythonhosted.org/packages/83/0c/7c1528f098aeb75a97de2bae18c530f56959fb7ad6c882db45d9884d6edc/grpcio-1.78.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:459ab414b35f4496138d0ecd735fed26f1318af5e52cb1efbc82a09f0d5aa911", size = 6524477, upload-time = "2026-02-06T09:55:07.111Z" }, + { url = "https://files.pythonhosted.org/packages/8d/52/e7c1f3688f949058e19a011c4e0dec973da3d0ae5e033909677f967ae1f4/grpcio-1.78.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:082653eecbdf290e6e3e2c276ab2c54b9e7c299e07f4221872380312d8cf395e", size = 7198266, upload-time = "2026-02-06T09:55:10.016Z" }, + { url = "https://files.pythonhosted.org/packages/e5/61/8ac32517c1e856677282c34f2e7812d6c328fa02b8f4067ab80e77fdc9c9/grpcio-1.78.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85f93781028ec63f383f6bc90db785a016319c561cc11151fbb7b34e0d012303", size = 6730552, upload-time = "2026-02-06T09:55:12.207Z" }, + { url = "https://files.pythonhosted.org/packages/bd/98/b8ee0158199250220734f620b12e4a345955ac7329cfd908d0bf0fda77f0/grpcio-1.78.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f12857d24d98441af6a1d5c87442d624411db486f7ba12550b07788f74b67b04", size = 7304296, upload-time = "2026-02-06T09:55:15.044Z" }, + { url = "https://files.pythonhosted.org/packages/bd/0f/7b72762e0d8840b58032a56fdbd02b78fc645b9fa993d71abf04edbc54f4/grpcio-1.78.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5397fff416b79e4b284959642a4e95ac4b0f1ece82c9993658e0e477d40551ec", size = 8288298, upload-time = "2026-02-06T09:55:17.276Z" }, + { url = "https://files.pythonhosted.org/packages/24/ae/ae4ce56bc5bb5caa3a486d60f5f6083ac3469228faa734362487176c15c5/grpcio-1.78.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fbe6e89c7ffb48518384068321621b2a69cab509f58e40e4399fdd378fa6d074", size = 7730953, upload-time = "2026-02-06T09:55:19.545Z" }, + { url = "https://files.pythonhosted.org/packages/b5/6e/8052e3a28eb6a820c372b2eb4b5e32d195c661e137d3eca94d534a4cfd8a/grpcio-1.78.0-cp311-cp311-win32.whl", hash = "sha256:6092beabe1966a3229f599d7088b38dfc8ffa1608b5b5cdda31e591e6500f856", size = 4076503, upload-time = "2026-02-06T09:55:21.521Z" }, + { url = "https://files.pythonhosted.org/packages/08/62/f22c98c5265dfad327251fa2f840b591b1df5f5e15d88b19c18c86965b27/grpcio-1.78.0-cp311-cp311-win_amd64.whl", hash = "sha256:1afa62af6e23f88629f2b29ec9e52ec7c65a7176c1e0a83292b93c76ca882558", size = 4799767, upload-time = "2026-02-06T09:55:24.107Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f4/7384ed0178203d6074446b3c4f46c90a22ddf7ae0b3aee521627f54cfc2a/grpcio-1.78.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:f9ab915a267fc47c7e88c387a3a28325b58c898e23d4995f765728f4e3dedb97", size = 5913985, upload-time = "2026-02-06T09:55:26.832Z" }, + { url = "https://files.pythonhosted.org/packages/81/ed/be1caa25f06594463f685b3790b320f18aea49b33166f4141bfdc2bfb236/grpcio-1.78.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3f8904a8165ab21e07e58bf3e30a73f4dffc7a1e0dbc32d51c61b5360d26f43e", size = 11811853, upload-time = "2026-02-06T09:55:29.224Z" }, + { url = "https://files.pythonhosted.org/packages/24/a7/f06d151afc4e64b7e3cc3e872d331d011c279aaab02831e40a81c691fb65/grpcio-1.78.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:859b13906ce098c0b493af92142ad051bf64c7870fa58a123911c88606714996", size = 6475766, upload-time = "2026-02-06T09:55:31.825Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a8/4482922da832ec0082d0f2cc3a10976d84a7424707f25780b82814aafc0a/grpcio-1.78.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b2342d87af32790f934a79c3112641e7b27d63c261b8b4395350dad43eff1dc7", size = 7170027, upload-time = "2026-02-06T09:55:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/54/bf/f4a3b9693e35d25b24b0b39fa46d7d8a3c439e0a3036c3451764678fec20/grpcio-1.78.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12a771591ae40bc65ba67048fa52ef4f0e6db8279e595fd349f9dfddeef571f9", size = 6690766, upload-time = "2026-02-06T09:55:36.902Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/521875265cc99fe5ad4c5a17010018085cae2810a928bf15ebe7d8bcd9cc/grpcio-1.78.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:185dea0d5260cbb2d224c507bf2a5444d5abbb1fa3594c1ed7e4c709d5eb8383", size = 7266161, upload-time = "2026-02-06T09:55:39.824Z" }, + { url = "https://files.pythonhosted.org/packages/05/86/296a82844fd40a4ad4a95f100b55044b4f817dece732bf686aea1a284147/grpcio-1.78.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51b13f9aed9d59ee389ad666b8c2214cc87b5de258fa712f9ab05f922e3896c6", size = 8253303, upload-time = "2026-02-06T09:55:42.353Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e4/ea3c0caf5468537f27ad5aab92b681ed7cc0ef5f8c9196d3fd42c8c2286b/grpcio-1.78.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd5f135b1bd58ab088930b3c613455796dfa0393626a6972663ccdda5b4ac6ce", size = 7698222, upload-time = "2026-02-06T09:55:44.629Z" }, + { url = "https://files.pythonhosted.org/packages/d7/47/7f05f81e4bb6b831e93271fb12fd52ba7b319b5402cbc101d588f435df00/grpcio-1.78.0-cp312-cp312-win32.whl", hash = "sha256:94309f498bcc07e5a7d16089ab984d42ad96af1d94b5a4eb966a266d9fcabf68", size = 4066123, upload-time = "2026-02-06T09:55:47.644Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e7/d6914822c88aa2974dbbd10903d801a28a19ce9cd8bad7e694cbbcf61528/grpcio-1.78.0-cp312-cp312-win_amd64.whl", hash = "sha256:9566fe4ababbb2610c39190791e5b829869351d14369603702e890ef3ad2d06e", size = 4797657, upload-time = "2026-02-06T09:55:49.86Z" }, + { url = "https://files.pythonhosted.org/packages/05/a9/8f75894993895f361ed8636cd9237f4ab39ef87fd30db17467235ed1c045/grpcio-1.78.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:ce3a90455492bf8bfa38e56fbbe1dbd4f872a3d8eeaf7337dc3b1c8aa28c271b", size = 5920143, upload-time = "2026-02-06T09:55:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/55/06/0b78408e938ac424100100fd081189451b472236e8a3a1f6500390dc4954/grpcio-1.78.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:2bf5e2e163b356978b23652c4818ce4759d40f4712ee9ec5a83c4be6f8c23a3a", size = 11803926, upload-time = "2026-02-06T09:55:55.494Z" }, + { url = "https://files.pythonhosted.org/packages/88/93/b59fe7832ff6ae3c78b813ea43dac60e295fa03606d14d89d2e0ec29f4f3/grpcio-1.78.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8f2ac84905d12918e4e55a16da17939eb63e433dc11b677267c35568aa63fc84", size = 6478628, upload-time = "2026-02-06T09:55:58.533Z" }, + { url = "https://files.pythonhosted.org/packages/ed/df/e67e3734527f9926b7d9c0dde6cd998d1d26850c3ed8eeec81297967ac67/grpcio-1.78.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b58f37edab4a3881bc6c9bca52670610e0c9ca14e2ea3cf9debf185b870457fb", size = 7173574, upload-time = "2026-02-06T09:56:01.786Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/cc03fffb07bfba982a9ec097b164e8835546980aec25ecfa5f9c1a47e022/grpcio-1.78.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:735e38e176a88ce41840c21bb49098ab66177c64c82426e24e0082500cc68af5", size = 6692639, upload-time = "2026-02-06T09:56:04.529Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9a/289c32e301b85bdb67d7ec68b752155e674ee3ba2173a1858f118e399ef3/grpcio-1.78.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2045397e63a7a0ee7957c25f7dbb36ddc110e0cfb418403d110c0a7a68a844e9", size = 7268838, upload-time = "2026-02-06T09:56:08.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/79/1be93f32add280461fa4773880196572563e9c8510861ac2da0ea0f892b6/grpcio-1.78.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9f136fbafe7ccf4ac7e8e0c28b31066e810be52d6e344ef954a3a70234e1702", size = 8251878, upload-time = "2026-02-06T09:56:10.914Z" }, + { url = "https://files.pythonhosted.org/packages/65/65/793f8e95296ab92e4164593674ae6291b204bb5f67f9d4a711489cd30ffa/grpcio-1.78.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:748b6138585379c737adc08aeffd21222abbda1a86a0dca2a39682feb9196c20", size = 7695412, upload-time = "2026-02-06T09:56:13.593Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/1e233fe697ecc82845942c2822ed06bb522e70d6771c28d5528e4c50f6a4/grpcio-1.78.0-cp313-cp313-win32.whl", hash = "sha256:271c73e6e5676afe4fc52907686670c7cea22ab2310b76a59b678403ed40d670", size = 4064899, upload-time = "2026-02-06T09:56:15.601Z" }, + { url = "https://files.pythonhosted.org/packages/4d/27/d86b89e36de8a951501fb06a0f38df19853210f341d0b28f83f4aa0ffa08/grpcio-1.78.0-cp313-cp313-win_amd64.whl", hash = "sha256:f2d4e43ee362adfc05994ed479334d5a451ab7bc3f3fee1b796b8ca66895acb4", size = 4797393, upload-time = "2026-02-06T09:56:17.882Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/b56e43e3c968bfe822fa6ce5bca10d5c723aa40875b48791ce1029bb78c7/grpcio-1.78.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:e87cbc002b6f440482b3519e36e1313eb5443e9e9e73d6a52d43bd2004fcfd8e", size = 5920591, upload-time = "2026-02-06T09:56:20.758Z" }, + { url = "https://files.pythonhosted.org/packages/5d/81/1f3b65bd30c334167bfa8b0d23300a44e2725ce39bba5b76a2460d85f745/grpcio-1.78.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c41bc64626db62e72afec66b0c8a0da76491510015417c127bfc53b2fe6d7f7f", size = 11813685, upload-time = "2026-02-06T09:56:24.315Z" }, + { url = "https://files.pythonhosted.org/packages/0e/1c/bbe2f8216a5bd3036119c544d63c2e592bdf4a8ec6e4a1867592f4586b26/grpcio-1.78.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8dfffba826efcf366b1e3ccc37e67afe676f290e13a3b48d31a46739f80a8724", size = 6487803, upload-time = "2026-02-06T09:56:27.367Z" }, + { url = "https://files.pythonhosted.org/packages/16/5c/a6b2419723ea7ddce6308259a55e8e7593d88464ce8db9f4aa857aba96fa/grpcio-1.78.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:74be1268d1439eaaf552c698cdb11cd594f0c49295ae6bb72c34ee31abbe611b", size = 7173206, upload-time = "2026-02-06T09:56:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/df/1e/b8801345629a415ea7e26c83d75eb5dbe91b07ffe5210cc517348a8d4218/grpcio-1.78.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be63c88b32e6c0f1429f1398ca5c09bc64b0d80950c8bb7807d7d7fb36fb84c7", size = 6693826, upload-time = "2026-02-06T09:56:32.305Z" }, + { url = "https://files.pythonhosted.org/packages/34/84/0de28eac0377742679a510784f049738a80424b17287739fc47d63c2439e/grpcio-1.78.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c586ac70e855c721bda8f548d38c3ca66ac791dc49b66a8281a1f99db85e452", size = 7277897, upload-time = "2026-02-06T09:56:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9c/ad8685cfe20559a9edb66f735afdcb2b7d3de69b13666fdfc542e1916ebd/grpcio-1.78.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:35eb275bf1751d2ffbd8f57cdbc46058e857cf3971041521b78b7db94bdaf127", size = 8252404, upload-time = "2026-02-06T09:56:37.553Z" }, + { url = "https://files.pythonhosted.org/packages/3c/05/33a7a4985586f27e1de4803887c417ec7ced145ebd069bc38a9607059e2b/grpcio-1.78.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:207db540302c884b8848036b80db352a832b99dfdf41db1eb554c2c2c7800f65", size = 7696837, upload-time = "2026-02-06T09:56:40.173Z" }, + { url = "https://files.pythonhosted.org/packages/73/77/7382241caf88729b106e49e7d18e3116216c778e6a7e833826eb96de22f7/grpcio-1.78.0-cp314-cp314-win32.whl", hash = "sha256:57bab6deef2f4f1ca76cc04565df38dc5713ae6c17de690721bdf30cb1e0545c", size = 4142439, upload-time = "2026-02-06T09:56:43.258Z" }, + { url = "https://files.pythonhosted.org/packages/48/b2/b096ccce418882fbfda4f7496f9357aaa9a5af1896a9a7f60d9f2b275a06/grpcio-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:dce09d6116df20a96acfdbf85e4866258c3758180e8c49845d6ba8248b6d0bbb", size = 4929852, upload-time = "2026-02-06T09:56:45.885Z" }, +] + +[[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 = "hf-xet" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/6e/0f11bacf08a67f7fb5ee09740f2ca54163863b07b70d579356e9222ce5d8/hf_xet-1.2.0.tar.gz", hash = "sha256:a8c27070ca547293b6890c4bf389f713f80e8c478631432962bb7f4bc0bd7d7f", size = 506020, upload-time = "2025-10-24T19:04:32.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/a5/85ef910a0aa034a2abcfadc360ab5ac6f6bc4e9112349bd40ca97551cff0/hf_xet-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:ceeefcd1b7aed4956ae8499e2199607765fbd1c60510752003b6cc0b8413b649", size = 2861870, upload-time = "2025-10-24T19:04:11.422Z" }, + { url = "https://files.pythonhosted.org/packages/ea/40/e2e0a7eb9a51fe8828ba2d47fe22a7e74914ea8a0db68a18c3aa7449c767/hf_xet-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b70218dd548e9840224df5638fdc94bd033552963cfa97f9170829381179c813", size = 2717584, upload-time = "2025-10-24T19:04:09.586Z" }, + { url = "https://files.pythonhosted.org/packages/a5/7d/daf7f8bc4594fdd59a8a596f9e3886133fdc68e675292218a5e4c1b7e834/hf_xet-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d40b18769bb9a8bc82a9ede575ce1a44c75eb80e7375a01d76259089529b5dc", size = 3315004, upload-time = "2025-10-24T19:04:00.314Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/45ea2f605fbf6d81c8b21e4d970b168b18a53515923010c312c06cd83164/hf_xet-1.2.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd3a6027d59cfb60177c12d6424e31f4b5ff13d8e3a1247b3a584bf8977e6df5", size = 3222636, upload-time = "2025-10-24T19:03:58.111Z" }, + { url = "https://files.pythonhosted.org/packages/4a/1d/04513e3cab8f29ab8c109d309ddd21a2705afab9d52f2ba1151e0c14f086/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6de1fc44f58f6dd937956c8d304d8c2dea264c80680bcfa61ca4a15e7b76780f", size = 3408448, upload-time = "2025-10-24T19:04:20.951Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7c/60a2756d7feec7387db3a1176c632357632fbe7849fce576c5559d4520c7/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f182f264ed2acd566c514e45da9f2119110e48a87a327ca271027904c70c5832", size = 3503401, upload-time = "2025-10-24T19:04:22.549Z" }, + { url = "https://files.pythonhosted.org/packages/4e/64/48fffbd67fb418ab07451e4ce641a70de1c40c10a13e25325e24858ebe5a/hf_xet-1.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:293a7a3787e5c95d7be1857358a9130694a9c6021de3f27fa233f37267174382", size = 2900866, upload-time = "2025-10-24T19:04:33.461Z" }, + { url = "https://files.pythonhosted.org/packages/e2/51/f7e2caae42f80af886db414d4e9885fac959330509089f97cccb339c6b87/hf_xet-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:10bfab528b968c70e062607f663e21e34e2bba349e8038db546646875495179e", size = 2861861, upload-time = "2025-10-24T19:04:19.01Z" }, + { url = "https://files.pythonhosted.org/packages/6e/1d/a641a88b69994f9371bd347f1dd35e5d1e2e2460a2e350c8d5165fc62005/hf_xet-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a212e842647b02eb6a911187dc878e79c4aa0aa397e88dd3b26761676e8c1f8", size = 2717699, upload-time = "2025-10-24T19:04:17.306Z" }, + { url = "https://files.pythonhosted.org/packages/df/e0/e5e9bba7d15f0318955f7ec3f4af13f92e773fbb368c0b8008a5acbcb12f/hf_xet-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30e06daccb3a7d4c065f34fc26c14c74f4653069bb2b194e7f18f17cbe9939c0", size = 3314885, upload-time = "2025-10-24T19:04:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/21/90/b7fe5ff6f2b7b8cbdf1bd56145f863c90a5807d9758a549bf3d916aa4dec/hf_xet-1.2.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:29c8fc913a529ec0a91867ce3d119ac1aac966e098cf49501800c870328cc090", size = 3221550, upload-time = "2025-10-24T19:04:05.55Z" }, + { url = "https://files.pythonhosted.org/packages/6f/cb/73f276f0a7ce46cc6a6ec7d6c7d61cbfe5f2e107123d9bbd0193c355f106/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e159cbfcfbb29f920db2c09ed8b660eb894640d284f102ada929b6e3dc410a", size = 3408010, upload-time = "2025-10-24T19:04:28.598Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1e/d642a12caa78171f4be64f7cd9c40e3ca5279d055d0873188a58c0f5fbb9/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c91d5ae931510107f148874e9e2de8a16052b6f1b3ca3c1b12f15ccb491390f", size = 3503264, upload-time = "2025-10-24T19:04:30.397Z" }, + { url = "https://files.pythonhosted.org/packages/17/b5/33764714923fa1ff922770f7ed18c2daae034d21ae6e10dbf4347c854154/hf_xet-1.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:210d577732b519ac6ede149d2f2f34049d44e8622bf14eb3d63bbcd2d4b332dc", size = 2901071, upload-time = "2025-10-24T19:04:37.463Z" }, + { url = "https://files.pythonhosted.org/packages/96/2d/22338486473df5923a9ab7107d375dbef9173c338ebef5098ef593d2b560/hf_xet-1.2.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:46740d4ac024a7ca9b22bebf77460ff43332868b661186a8e46c227fdae01848", size = 2866099, upload-time = "2025-10-24T19:04:15.366Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8c/c5becfa53234299bc2210ba314eaaae36c2875e0045809b82e40a9544f0c/hf_xet-1.2.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:27df617a076420d8845bea087f59303da8be17ed7ec0cd7ee3b9b9f579dff0e4", size = 2722178, upload-time = "2025-10-24T19:04:13.695Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/cf3ab0b652b082e66876d08da57fcc6fa2f0e6c70dfbbafbd470bb73eb47/hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3651fd5bfe0281951b988c0facbe726aa5e347b103a675f49a3fa8144c7968fd", size = 3320214, upload-time = "2025-10-24T19:04:03.596Z" }, + { url = "https://files.pythonhosted.org/packages/46/92/3f7ec4a1b6a65bf45b059b6d4a5d38988f63e193056de2f420137e3c3244/hf_xet-1.2.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d06fa97c8562fb3ee7a378dd9b51e343bc5bc8190254202c9771029152f5e08c", size = 3229054, upload-time = "2025-10-24T19:04:01.949Z" }, + { url = "https://files.pythonhosted.org/packages/0b/dd/7ac658d54b9fb7999a0ccb07ad863b413cbaf5cf172f48ebcd9497ec7263/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4c1428c9ae73ec0939410ec73023c4f842927f39db09b063b9482dac5a3bb737", size = 3413812, upload-time = "2025-10-24T19:04:24.585Z" }, + { url = "https://files.pythonhosted.org/packages/92/68/89ac4e5b12a9ff6286a12174c8538a5930e2ed662091dd2572bbe0a18c8a/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a55558084c16b09b5ed32ab9ed38421e2d87cf3f1f89815764d1177081b99865", size = 3508920, upload-time = "2025-10-24T19:04:26.927Z" }, + { url = "https://files.pythonhosted.org/packages/cb/44/870d44b30e1dcfb6a65932e3e1506c103a8a5aea9103c337e7a53180322c/hf_xet-1.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:e6584a52253f72c9f52f9e549d5895ca7a471608495c4ecaa6cc73dba2b24d69", size = 2905735, upload-time = "2025-10-24T19:04:35.928Z" }, +] + +[[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 = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + +[[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 = "huggingface-hub" +version = "0.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/63/4910c5fa9128fdadf6a9c5ac138e8b1b6cee4ca44bf7915bbfbce4e355ee/huggingface_hub-0.36.0.tar.gz", hash = "sha256:47b3f0e2539c39bf5cde015d63b72ec49baff67b6931c3d97f3f84532e2b8d25", size = 463358, upload-time = "2025-10-23T12:12:01.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/bd/1a875e0d592d447cbc02805fd3fe0f497714d6a2583f59d14fa9ebad96eb/huggingface_hub-0.36.0-py3-none-any.whl", hash = "sha256:7bcc9ad17d5b3f07b57c78e79d527102d08313caa278a641993acddcb894548d", size = 566094, upload-time = "2025-10-23T12:11:59.557Z" }, +] + +[[package]] +name = "identify" +version = "2.6.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "ifaddr" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/ac/fb4c578f4a3256561548cd825646680edcadb9440f3f68add95ade1eb791/ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4", size = 10485, upload-time = "2022-06-15T21:40:27.561Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", size = 12314, upload-time = "2022-06-15T21:40:25.756Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jiter" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/9d/e0660989c1370e25848bb4c52d061c71837239738ad937e83edca174c273/jiter-0.12.0.tar.gz", hash = "sha256:64dfcd7d5c168b38d3f9f8bba7fc639edb3418abcc74f22fdbe6b8938293f30b", size = 168294, upload-time = "2025-11-09T20:49:23.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/f9/eaca4633486b527ebe7e681c431f529b63fe2709e7c5242fc0f43f77ce63/jiter-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d8f8a7e317190b2c2d60eb2e8aa835270b008139562d70fe732e1c0020ec53c9", size = 316435, upload-time = "2025-11-09T20:47:02.087Z" }, + { url = "https://files.pythonhosted.org/packages/10/c1/40c9f7c22f5e6ff715f28113ebaba27ab85f9af2660ad6e1dd6425d14c19/jiter-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2218228a077e784c6c8f1a8e5d6b8cb1dea62ce25811c356364848554b2056cd", size = 320548, upload-time = "2025-11-09T20:47:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1b/efbb68fe87e7711b00d2cfd1f26bb4bfc25a10539aefeaa7727329ffb9cb/jiter-0.12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9354ccaa2982bf2188fd5f57f79f800ef622ec67beb8329903abf6b10da7d423", size = 351915, upload-time = "2025-11-09T20:47:05.171Z" }, + { url = "https://files.pythonhosted.org/packages/15/2d/c06e659888c128ad1e838123d0638f0efad90cc30860cb5f74dd3f2fc0b3/jiter-0.12.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2607185ea89b4af9a604d4c7ec40e45d3ad03ee66998b031134bc510232bb7", size = 368966, upload-time = "2025-11-09T20:47:06.508Z" }, + { url = "https://files.pythonhosted.org/packages/6b/20/058db4ae5fb07cf6a4ab2e9b9294416f606d8e467fb74c2184b2a1eeacba/jiter-0.12.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a585a5e42d25f2e71db5f10b171f5e5ea641d3aa44f7df745aa965606111cc2", size = 482047, upload-time = "2025-11-09T20:47:08.382Z" }, + { url = "https://files.pythonhosted.org/packages/49/bb/dc2b1c122275e1de2eb12905015d61e8316b2f888bdaac34221c301495d6/jiter-0.12.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd9e21d34edff5a663c631f850edcb786719c960ce887a5661e9c828a53a95d9", size = 380835, upload-time = "2025-11-09T20:47:09.81Z" }, + { url = "https://files.pythonhosted.org/packages/23/7d/38f9cd337575349de16da575ee57ddb2d5a64d425c9367f5ef9e4612e32e/jiter-0.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a612534770470686cd5431478dc5a1b660eceb410abade6b1b74e320ca98de6", size = 364587, upload-time = "2025-11-09T20:47:11.529Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a3/b13e8e61e70f0bb06085099c4e2462647f53cc2ca97614f7fedcaa2bb9f3/jiter-0.12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3985aea37d40a908f887b34d05111e0aae822943796ebf8338877fee2ab67725", size = 390492, upload-time = "2025-11-09T20:47:12.993Z" }, + { url = "https://files.pythonhosted.org/packages/07/71/e0d11422ed027e21422f7bc1883c61deba2d9752b720538430c1deadfbca/jiter-0.12.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b1207af186495f48f72529f8d86671903c8c10127cac6381b11dddc4aaa52df6", size = 522046, upload-time = "2025-11-09T20:47:14.6Z" }, + { url = "https://files.pythonhosted.org/packages/9f/59/b968a9aa7102a8375dbbdfbd2aeebe563c7e5dddf0f47c9ef1588a97e224/jiter-0.12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ef2fb241de583934c9915a33120ecc06d94aa3381a134570f59eed784e87001e", size = 513392, upload-time = "2025-11-09T20:47:16.011Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e4/7df62002499080dbd61b505c5cb351aa09e9959d176cac2aa8da6f93b13b/jiter-0.12.0-cp311-cp311-win32.whl", hash = "sha256:453b6035672fecce8007465896a25b28a6b59cfe8fbc974b2563a92f5a92a67c", size = 206096, upload-time = "2025-11-09T20:47:17.344Z" }, + { url = "https://files.pythonhosted.org/packages/bb/60/1032b30ae0572196b0de0e87dce3b6c26a1eff71aad5fe43dee3082d32e0/jiter-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:ca264b9603973c2ad9435c71a8ec8b49f8f715ab5ba421c85a51cde9887e421f", size = 204899, upload-time = "2025-11-09T20:47:19.365Z" }, + { url = "https://files.pythonhosted.org/packages/49/d5/c145e526fccdb834063fb45c071df78b0cc426bbaf6de38b0781f45d956f/jiter-0.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:cb00ef392e7d684f2754598c02c409f376ddcef857aae796d559e6cacc2d78a5", size = 188070, upload-time = "2025-11-09T20:47:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/92/c9/5b9f7b4983f1b542c64e84165075335e8a236fa9e2ea03a0c79780062be8/jiter-0.12.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:305e061fa82f4680607a775b2e8e0bcb071cd2205ac38e6ef48c8dd5ebe1cf37", size = 314449, upload-time = "2025-11-09T20:47:22.999Z" }, + { url = "https://files.pythonhosted.org/packages/98/6e/e8efa0e78de00db0aee82c0cf9e8b3f2027efd7f8a71f859d8f4be8e98ef/jiter-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5c1860627048e302a528333c9307c818c547f214d8659b0705d2195e1a94b274", size = 319855, upload-time = "2025-11-09T20:47:24.779Z" }, + { url = "https://files.pythonhosted.org/packages/20/26/894cd88e60b5d58af53bec5c6759d1292bd0b37a8b5f60f07abf7a63ae5f/jiter-0.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df37577a4f8408f7e0ec3205d2a8f87672af8f17008358063a4d6425b6081ce3", size = 350171, upload-time = "2025-11-09T20:47:26.469Z" }, + { url = "https://files.pythonhosted.org/packages/f5/27/a7b818b9979ac31b3763d25f3653ec3a954044d5e9f5d87f2f247d679fd1/jiter-0.12.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:75fdd787356c1c13a4f40b43c2156276ef7a71eb487d98472476476d803fb2cf", size = 365590, upload-time = "2025-11-09T20:47:27.918Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7e/e46195801a97673a83746170b17984aa8ac4a455746354516d02ca5541b4/jiter-0.12.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1eb5db8d9c65b112aacf14fcd0faae9913d07a8afea5ed06ccdd12b724e966a1", size = 479462, upload-time = "2025-11-09T20:47:29.654Z" }, + { url = "https://files.pythonhosted.org/packages/ca/75/f833bfb009ab4bd11b1c9406d333e3b4357709ed0570bb48c7c06d78c7dd/jiter-0.12.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:73c568cc27c473f82480abc15d1301adf333a7ea4f2e813d6a2c7d8b6ba8d0df", size = 378983, upload-time = "2025-11-09T20:47:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/71/b3/7a69d77943cc837d30165643db753471aff5df39692d598da880a6e51c24/jiter-0.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4321e8a3d868919bcb1abb1db550d41f2b5b326f72df29e53b2df8b006eb9403", size = 361328, upload-time = "2025-11-09T20:47:33.286Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ac/a78f90caf48d65ba70d8c6efc6f23150bc39dc3389d65bbec2a95c7bc628/jiter-0.12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0a51bad79f8cc9cac2b4b705039f814049142e0050f30d91695a2d9a6611f126", size = 386740, upload-time = "2025-11-09T20:47:34.703Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/5d31c2cc8e1b6a6bcf3c5721e4ca0a3633d1ab4754b09bc7084f6c4f5327/jiter-0.12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2a67b678f6a5f1dd6c36d642d7db83e456bc8b104788262aaefc11a22339f5a9", size = 520875, upload-time = "2025-11-09T20:47:36.058Z" }, + { url = "https://files.pythonhosted.org/packages/30/b5/4df540fae4e9f68c54b8dab004bd8c943a752f0b00efd6e7d64aa3850339/jiter-0.12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efe1a211fe1fd14762adea941e3cfd6c611a136e28da6c39272dbb7a1bbe6a86", size = 511457, upload-time = "2025-11-09T20:47:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/07/65/86b74010e450a1a77b2c1aabb91d4a91dd3cd5afce99f34d75fd1ac64b19/jiter-0.12.0-cp312-cp312-win32.whl", hash = "sha256:d779d97c834b4278276ec703dc3fc1735fca50af63eb7262f05bdb4e62203d44", size = 204546, upload-time = "2025-11-09T20:47:40.47Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/6659f537f9562d963488e3e55573498a442503ced01f7e169e96a6110383/jiter-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:e8269062060212b373316fe69236096aaf4c49022d267c6736eebd66bbbc60bb", size = 205196, upload-time = "2025-11-09T20:47:41.794Z" }, + { url = "https://files.pythonhosted.org/packages/21/f4/935304f5169edadfec7f9c01eacbce4c90bb9a82035ac1de1f3bd2d40be6/jiter-0.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:06cb970936c65de926d648af0ed3d21857f026b1cf5525cb2947aa5e01e05789", size = 186100, upload-time = "2025-11-09T20:47:43.007Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a6/97209693b177716e22576ee1161674d1d58029eb178e01866a0422b69224/jiter-0.12.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:6cc49d5130a14b732e0612bc76ae8db3b49898732223ef8b7599aa8d9810683e", size = 313658, upload-time = "2025-11-09T20:47:44.424Z" }, + { url = "https://files.pythonhosted.org/packages/06/4d/125c5c1537c7d8ee73ad3d530a442d6c619714b95027143f1b61c0b4dfe0/jiter-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:37f27a32ce36364d2fa4f7fdc507279db604d27d239ea2e044c8f148410defe1", size = 318605, upload-time = "2025-11-09T20:47:45.973Z" }, + { url = "https://files.pythonhosted.org/packages/99/bf/a840b89847885064c41a5f52de6e312e91fa84a520848ee56c97e4fa0205/jiter-0.12.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbc0944aa3d4b4773e348cda635252824a78f4ba44328e042ef1ff3f6080d1cf", size = 349803, upload-time = "2025-11-09T20:47:47.535Z" }, + { url = "https://files.pythonhosted.org/packages/8a/88/e63441c28e0db50e305ae23e19c1d8fae012d78ed55365da392c1f34b09c/jiter-0.12.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:da25c62d4ee1ffbacb97fac6dfe4dcd6759ebdc9015991e92a6eae5816287f44", size = 365120, upload-time = "2025-11-09T20:47:49.284Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7c/49b02714af4343970eb8aca63396bc1c82fa01197dbb1e9b0d274b550d4e/jiter-0.12.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:048485c654b838140b007390b8182ba9774621103bd4d77c9c3f6f117474ba45", size = 479918, upload-time = "2025-11-09T20:47:50.807Z" }, + { url = "https://files.pythonhosted.org/packages/69/ba/0a809817fdd5a1db80490b9150645f3aae16afad166960bcd562be194f3b/jiter-0.12.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:635e737fbb7315bef0037c19b88b799143d2d7d3507e61a76751025226b3ac87", size = 379008, upload-time = "2025-11-09T20:47:52.211Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c3/c9fc0232e736c8877d9e6d83d6eeb0ba4e90c6c073835cc2e8f73fdeef51/jiter-0.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e017c417b1ebda911bd13b1e40612704b1f5420e30695112efdbed8a4b389ed", size = 361785, upload-time = "2025-11-09T20:47:53.512Z" }, + { url = "https://files.pythonhosted.org/packages/96/61/61f69b7e442e97ca6cd53086ddc1cf59fb830549bc72c0a293713a60c525/jiter-0.12.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:89b0bfb8b2bf2351fba36bb211ef8bfceba73ef58e7f0c68fb67b5a2795ca2f9", size = 386108, upload-time = "2025-11-09T20:47:54.893Z" }, + { url = "https://files.pythonhosted.org/packages/e9/2e/76bb3332f28550c8f1eba3bf6e5efe211efda0ddbbaf24976bc7078d42a5/jiter-0.12.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:f5aa5427a629a824a543672778c9ce0c5e556550d1569bb6ea28a85015287626", size = 519937, upload-time = "2025-11-09T20:47:56.253Z" }, + { url = "https://files.pythonhosted.org/packages/84/d6/fa96efa87dc8bff2094fb947f51f66368fa56d8d4fc9e77b25d7fbb23375/jiter-0.12.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed53b3d6acbcb0fd0b90f20c7cb3b24c357fe82a3518934d4edfa8c6898e498c", size = 510853, upload-time = "2025-11-09T20:47:58.32Z" }, + { url = "https://files.pythonhosted.org/packages/8a/28/93f67fdb4d5904a708119a6ab58a8f1ec226ff10a94a282e0215402a8462/jiter-0.12.0-cp313-cp313-win32.whl", hash = "sha256:4747de73d6b8c78f2e253a2787930f4fffc68da7fa319739f57437f95963c4de", size = 204699, upload-time = "2025-11-09T20:47:59.686Z" }, + { url = "https://files.pythonhosted.org/packages/c4/1f/30b0eb087045a0abe2a5c9c0c0c8da110875a1d3be83afd4a9a4e548be3c/jiter-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:e25012eb0c456fcc13354255d0338cd5397cce26c77b2832b3c4e2e255ea5d9a", size = 204258, upload-time = "2025-11-09T20:48:01.01Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f4/2b4daf99b96bce6fc47971890b14b2a36aef88d7beb9f057fafa032c6141/jiter-0.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:c97b92c54fe6110138c872add030a1f99aea2401ddcdaa21edf74705a646dd60", size = 185503, upload-time = "2025-11-09T20:48:02.35Z" }, + { url = "https://files.pythonhosted.org/packages/39/ca/67bb15a7061d6fe20b9b2a2fd783e296a1e0f93468252c093481a2f00efa/jiter-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:53839b35a38f56b8be26a7851a48b89bc47e5d88e900929df10ed93b95fea3d6", size = 317965, upload-time = "2025-11-09T20:48:03.783Z" }, + { url = "https://files.pythonhosted.org/packages/18/af/1788031cd22e29c3b14bc6ca80b16a39a0b10e611367ffd480c06a259831/jiter-0.12.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94f669548e55c91ab47fef8bddd9c954dab1938644e715ea49d7e117015110a4", size = 345831, upload-time = "2025-11-09T20:48:05.55Z" }, + { url = "https://files.pythonhosted.org/packages/05/17/710bf8472d1dff0d3caf4ced6031060091c1320f84ee7d5dcbed1f352417/jiter-0.12.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:351d54f2b09a41600ffea43d081522d792e81dcfb915f6d2d242744c1cc48beb", size = 361272, upload-time = "2025-11-09T20:48:06.951Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f1/1dcc4618b59761fef92d10bcbb0b038b5160be653b003651566a185f1a5c/jiter-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2a5e90604620f94bf62264e7c2c038704d38217b7465b863896c6d7c902b06c7", size = 204604, upload-time = "2025-11-09T20:48:08.328Z" }, + { url = "https://files.pythonhosted.org/packages/d9/32/63cb1d9f1c5c6632a783c0052cde9ef7ba82688f7065e2f0d5f10a7e3edb/jiter-0.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:88ef757017e78d2860f96250f9393b7b577b06a956ad102c29c8237554380db3", size = 185628, upload-time = "2025-11-09T20:48:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/45c9f0dbe4a1416b2b9a8a6d1236459540f43d7fb8883cff769a8db0612d/jiter-0.12.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c46d927acd09c67a9fb1416df45c5a04c27e83aae969267e98fba35b74e99525", size = 312478, upload-time = "2025-11-09T20:48:10.898Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a7/54ae75613ba9e0f55fcb0bc5d1f807823b5167cc944e9333ff322e9f07dd/jiter-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:774ff60b27a84a85b27b88cd5583899c59940bcc126caca97eb2a9df6aa00c49", size = 318706, upload-time = "2025-11-09T20:48:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/2aa241ad2c10774baf6c37f8b8e1f39c07db358f1329f4eb40eba179c2a2/jiter-0.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5433fab222fb072237df3f637d01b81f040a07dcac1cb4a5c75c7aa9ed0bef1", size = 351894, upload-time = "2025-11-09T20:48:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/54/4f/0f2759522719133a9042781b18cc94e335b6d290f5e2d3e6899d6af933e3/jiter-0.12.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8c593c6e71c07866ec6bfb790e202a833eeec885022296aff6b9e0b92d6a70e", size = 365714, upload-time = "2025-11-09T20:48:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/dc/6f/806b895f476582c62a2f52c453151edd8a0fde5411b0497baaa41018e878/jiter-0.12.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:90d32894d4c6877a87ae00c6b915b609406819dce8bc0d4e962e4de2784e567e", size = 478989, upload-time = "2025-11-09T20:48:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/86/6c/012d894dc6e1033acd8db2b8346add33e413ec1c7c002598915278a37f79/jiter-0.12.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:798e46eed9eb10c3adbbacbd3bdb5ecd4cf7064e453d00dbef08802dae6937ff", size = 378615, upload-time = "2025-11-09T20:48:18.614Z" }, + { url = "https://files.pythonhosted.org/packages/87/30/d718d599f6700163e28e2c71c0bbaf6dace692e7df2592fd793ac9276717/jiter-0.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3f1368f0a6719ea80013a4eb90ba72e75d7ea67cfc7846db2ca504f3df0169a", size = 364745, upload-time = "2025-11-09T20:48:20.117Z" }, + { url = "https://files.pythonhosted.org/packages/8f/85/315b45ce4b6ddc7d7fceca24068543b02bdc8782942f4ee49d652e2cc89f/jiter-0.12.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:65f04a9d0b4406f7e51279710b27484af411896246200e461d80d3ba0caa901a", size = 386502, upload-time = "2025-11-09T20:48:21.543Z" }, + { url = "https://files.pythonhosted.org/packages/74/0b/ce0434fb40c5b24b368fe81b17074d2840748b4952256bab451b72290a49/jiter-0.12.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:fd990541982a24281d12b67a335e44f117e4c6cbad3c3b75c7dea68bf4ce3a67", size = 519845, upload-time = "2025-11-09T20:48:22.964Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a3/7a7a4488ba052767846b9c916d208b3ed114e3eb670ee984e4c565b9cf0d/jiter-0.12.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:b111b0e9152fa7df870ecaebb0bd30240d9f7fff1f2003bcb4ed0f519941820b", size = 510701, upload-time = "2025-11-09T20:48:24.483Z" }, + { url = "https://files.pythonhosted.org/packages/c3/16/052ffbf9d0467b70af24e30f91e0579e13ded0c17bb4a8eb2aed3cb60131/jiter-0.12.0-cp314-cp314-win32.whl", hash = "sha256:a78befb9cc0a45b5a5a0d537b06f8544c2ebb60d19d02c41ff15da28a9e22d42", size = 205029, upload-time = "2025-11-09T20:48:25.749Z" }, + { url = "https://files.pythonhosted.org/packages/e4/18/3cf1f3f0ccc789f76b9a754bdb7a6977e5d1d671ee97a9e14f7eb728d80e/jiter-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:e1fe01c082f6aafbe5c8faf0ff074f38dfb911d53f07ec333ca03f8f6226debf", size = 204960, upload-time = "2025-11-09T20:48:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/02/68/736821e52ecfdeeb0f024b8ab01b5a229f6b9293bbdb444c27efade50b0f/jiter-0.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:d72f3b5a432a4c546ea4bedc84cce0c3404874f1d1676260b9c7f048a9855451", size = 185529, upload-time = "2025-11-09T20:48:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/30/61/12ed8ee7a643cce29ac97c2281f9ce3956eb76b037e88d290f4ed0d41480/jiter-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e6ded41aeba3603f9728ed2b6196e4df875348ab97b28fc8afff115ed42ba7a7", size = 318974, upload-time = "2025-11-09T20:48:30.87Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c6/f3041ede6d0ed5e0e79ff0de4c8f14f401bbf196f2ef3971cdbe5fd08d1d/jiter-0.12.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a947920902420a6ada6ad51892082521978e9dd44a802663b001436e4b771684", size = 345932, upload-time = "2025-11-09T20:48:32.658Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5d/4d94835889edd01ad0e2dbfc05f7bdfaed46292e7b504a6ac7839aa00edb/jiter-0.12.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:add5e227e0554d3a52cf390a7635edaffdf4f8fce4fdbcef3cc2055bb396a30c", size = 367243, upload-time = "2025-11-09T20:48:34.093Z" }, + { url = "https://files.pythonhosted.org/packages/fd/76/0051b0ac2816253a99d27baf3dda198663aff882fa6ea7deeb94046da24e/jiter-0.12.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f9b1cda8fcb736250d7e8711d4580ebf004a46771432be0ae4796944b5dfa5d", size = 479315, upload-time = "2025-11-09T20:48:35.507Z" }, + { url = "https://files.pythonhosted.org/packages/70/ae/83f793acd68e5cb24e483f44f482a1a15601848b9b6f199dacb970098f77/jiter-0.12.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:deeb12a2223fe0135c7ff1356a143d57f95bbf1f4a66584f1fc74df21d86b993", size = 380714, upload-time = "2025-11-09T20:48:40.014Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/4808a88338ad2c228b1126b93fcd8ba145e919e886fe910d578230dabe3b/jiter-0.12.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c596cc0f4cb574877550ce4ecd51f8037469146addd676d7c1a30ebe6391923f", size = 365168, upload-time = "2025-11-09T20:48:41.462Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d4/04619a9e8095b42aef436b5aeb4c0282b4ff1b27d1db1508df9f5dc82750/jiter-0.12.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ab4c823b216a4aeab3fdbf579c5843165756bd9ad87cc6b1c65919c4715f783", size = 387893, upload-time = "2025-11-09T20:48:42.921Z" }, + { url = "https://files.pythonhosted.org/packages/17/ea/d3c7e62e4546fdc39197fa4a4315a563a89b95b6d54c0d25373842a59cbe/jiter-0.12.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e427eee51149edf962203ff8db75a7514ab89be5cb623fb9cea1f20b54f1107b", size = 520828, upload-time = "2025-11-09T20:48:44.278Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0b/c6d3562a03fd767e31cb119d9041ea7958c3c80cb3d753eafb19b3b18349/jiter-0.12.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:edb868841f84c111255ba5e80339d386d937ec1fdce419518ce1bd9370fac5b6", size = 511009, upload-time = "2025-11-09T20:48:45.726Z" }, + { url = "https://files.pythonhosted.org/packages/aa/51/2cb4468b3448a8385ebcd15059d325c9ce67df4e2758d133ab9442b19834/jiter-0.12.0-cp314-cp314t-win32.whl", hash = "sha256:8bbcfe2791dfdb7c5e48baf646d37a6a3dcb5a97a032017741dea9f817dca183", size = 205110, upload-time = "2025-11-09T20:48:47.033Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c5/ae5ec83dec9c2d1af805fd5fe8f74ebded9c8670c5210ec7820ce0dbeb1e/jiter-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2fa940963bf02e1d8226027ef461e36af472dea85d36054ff835aeed944dd873", size = 205223, upload-time = "2025-11-09T20:48:49.076Z" }, + { url = "https://files.pythonhosted.org/packages/97/9a/3c5391907277f0e55195550cf3fa8e293ae9ee0c00fb402fec1e38c0c82f/jiter-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:506c9708dd29b27288f9f8f1140c3cb0e3d8ddb045956d7757b1fa0e0f39a473", size = 185564, upload-time = "2025-11-09T20:48:50.376Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/5339ef1ecaa881c6948669956567a64d2670941925f245c434f494ffb0e5/jiter-0.12.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:4739a4657179ebf08f85914ce50332495811004cc1747852e8b2041ed2aab9b8", size = 311144, upload-time = "2025-11-09T20:49:10.503Z" }, + { url = "https://files.pythonhosted.org/packages/27/74/3446c652bffbd5e81ab354e388b1b5fc1d20daac34ee0ed11ff096b1b01a/jiter-0.12.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:41da8def934bf7bec16cb24bd33c0ca62126d2d45d81d17b864bd5ad721393c3", size = 305877, upload-time = "2025-11-09T20:49:12.269Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f4/ed76ef9043450f57aac2d4fbeb27175aa0eb9c38f833be6ef6379b3b9a86/jiter-0.12.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c44ee814f499c082e69872d426b624987dbc5943ab06e9bbaa4f81989fdb79e", size = 340419, upload-time = "2025-11-09T20:49:13.803Z" }, + { url = "https://files.pythonhosted.org/packages/21/01/857d4608f5edb0664aa791a3d45702e1a5bcfff9934da74035e7b9803846/jiter-0.12.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd2097de91cf03eaa27b3cbdb969addf83f0179c6afc41bbc4513705e013c65d", size = 347212, upload-time = "2025-11-09T20:49:15.643Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f5/12efb8ada5f5c9edc1d4555fe383c1fb2eac05ac5859258a72d61981d999/jiter-0.12.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:e8547883d7b96ef2e5fe22b88f8a4c8725a56e7f4abafff20fd5272d634c7ecb", size = 309974, upload-time = "2025-11-09T20:49:17.187Z" }, + { url = "https://files.pythonhosted.org/packages/85/15/d6eb3b770f6a0d332675141ab3962fd4a7c270ede3515d9f3583e1d28276/jiter-0.12.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:89163163c0934854a668ed783a2546a0617f71706a2551a4a0666d91ab365d6b", size = 304233, upload-time = "2025-11-09T20:49:18.734Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3e/e7e06743294eea2cf02ced6aa0ff2ad237367394e37a0e2b4a1108c67a36/jiter-0.12.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d96b264ab7d34bbb2312dedc47ce07cd53f06835eacbc16dde3761f47c3a9e7f", size = 338537, upload-time = "2025-11-09T20:49:20.317Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9c/6753e6522b8d0ef07d3a3d239426669e984fb0eba15a315cdbc1253904e4/jiter-0.12.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c24e864cb30ab82311c6425655b0cdab0a98c5d973b065c66a3f020740c2324c", size = 346110, upload-time = "2025-11-09T20:49:21.817Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "json-schema-to-pydantic" +version = "0.4.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/b1/fbcbda5e23ecb5ff987076d9caccb11d182a249cedd5424236aaca4e65b8/json_schema_to_pydantic-0.4.7.tar.gz", hash = "sha256:a6384825fee7609715641a1e5095ddf41aa3bdd06cd91d4a6b6d88a3dfcad920", size = 50054, upload-time = "2025-11-03T19:28:30.65Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/af/f64664df8d4d52a371195e16c6ff4419968ee3eb6f96b21e596e3623dcf3/json_schema_to_pydantic-0.4.7-py3-none-any.whl", hash = "sha256:e329506e42b63f9a0ae0a17f7082ec7fc7cb3326138ec5c24904e0af5a660e63", size = 14709, upload-time = "2025-11-03T19:28:29.168Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.23.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/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778, upload-time = "2024-07-08T18:40:05.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462, upload-time = "2024-07-08T18:40:00.165Z" }, +] + +[[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 = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" }, + { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" }, + { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" }, + { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" }, + { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, + { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, + { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, + { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, + { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, + { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, + { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" }, + { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" }, + { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, +] + +[[package]] +name = "litellm" +version = "1.82.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "click" }, + { name = "fastuuid" }, + { name = "httpx" }, + { name = "importlib-metadata" }, + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "tiktoken" }, + { name = "tokenizers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/12/010a86643f12ac0b004032d5927c260094299a84ed38b5ed20a8f8c7e3c4/litellm-1.82.2.tar.gz", hash = "sha256:f5f4c4049f344a88bf80b2e421bb927807687c99624515d7ff4152d533ec9dcb", size = 17353218, upload-time = "2026-03-13T21:24:24.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/e4/87e3ca82a8bf6e6bfffb42a539a1350dd6ced1b7169397bd439ba56fde10/litellm-1.82.2-py3-none-any.whl", hash = "sha256:641ed024774fa3d5b4dd9347f0efb1e31fa422fba2a6500aabedee085d1194cb", size = 15524224, upload-time = "2026-03-13T21:24:21.288Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markdown2" +version = "2.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/42/f8/b2ae8bf5f28f9b510ae097415e6e4cb63226bb28d7ee01aec03a755ba03b/markdown2-2.5.4.tar.gz", hash = "sha256:a09873f0b3c23dbfae589b0080587df52ad75bb09a5fa6559147554736676889", size = 145652, upload-time = "2025-07-27T16:16:24.307Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/06/2697b5043c3ecb720ce0d243fc7cf5024c0b5b1e450506e9b21939019963/markdown2-2.5.4-py3-none-any.whl", hash = "sha256:3c4b2934e677be7fec0e6f2de4410e116681f4ad50ec8e5ba7557be506d3f439", size = 49954, upload-time = "2025-07-27T16:16:23.026Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/86/de7e3a1cdcfc941483af70609edc06b83e7c8a0e0dc9ac325200a3f4d220/matplotlib-3.10.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6be43b667360fef5c754dda5d25a32e6307a03c204f3c0fc5468b78fa87b4160", size = 8251215, upload-time = "2025-12-10T22:55:16.175Z" }, + { url = "https://files.pythonhosted.org/packages/fd/14/baad3222f424b19ce6ad243c71de1ad9ec6b2e4eb1e458a48fdc6d120401/matplotlib-3.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2b336e2d91a3d7006864e0990c83b216fcdca64b5a6484912902cef87313d78", size = 8139625, upload-time = "2025-12-10T22:55:17.712Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a0/7024215e95d456de5883e6732e708d8187d9753a21d32f8ddb3befc0c445/matplotlib-3.10.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efb30e3baaea72ce5928e32bab719ab4770099079d66726a62b11b1ef7273be4", size = 8712614, upload-time = "2025-12-10T22:55:20.8Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f4/b8347351da9a5b3f41e26cf547252d861f685c6867d179a7c9d60ad50189/matplotlib-3.10.8-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d56a1efd5bfd61486c8bc968fa18734464556f0fb8e51690f4ac25d85cbbbbc2", size = 9540997, upload-time = "2025-12-10T22:55:23.258Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c0/c7b914e297efe0bc36917bf216b2acb91044b91e930e878ae12981e461e5/matplotlib-3.10.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238b7ce5717600615c895050239ec955d91f321c209dd110db988500558e70d6", size = 9596825, upload-time = "2025-12-10T22:55:25.217Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d3/a4bbc01c237ab710a1f22b4da72f4ff6d77eb4c7735ea9811a94ae239067/matplotlib-3.10.8-cp311-cp311-win_amd64.whl", hash = "sha256:18821ace09c763ec93aef5eeff087ee493a24051936d7b9ebcad9662f66501f9", size = 8135090, upload-time = "2025-12-10T22:55:27.162Z" }, + { url = "https://files.pythonhosted.org/packages/89/dd/a0b6588f102beab33ca6f5218b31725216577b2a24172f327eaf6417d5c9/matplotlib-3.10.8-cp311-cp311-win_arm64.whl", hash = "sha256:bab485bcf8b1c7d2060b4fcb6fc368a9e6f4cd754c9c2fea281f4be21df394a2", size = 8012377, upload-time = "2025-12-10T22:55:29.185Z" }, + { url = "https://files.pythonhosted.org/packages/9e/67/f997cdcbb514012eb0d10cd2b4b332667997fb5ebe26b8d41d04962fa0e6/matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a", size = 8260453, upload-time = "2025-12-10T22:55:30.709Z" }, + { url = "https://files.pythonhosted.org/packages/7e/65/07d5f5c7f7c994f12c768708bd2e17a4f01a2b0f44a1c9eccad872433e2e/matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58", size = 8148321, upload-time = "2025-12-10T22:55:33.265Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04", size = 8716944, upload-time = "2025-12-10T22:55:34.922Z" }, + { url = "https://files.pythonhosted.org/packages/00/f9/7638f5cc82ec8a7aa005de48622eecc3ed7c9854b96ba15bd76b7fd27574/matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f", size = 9550099, upload-time = "2025-12-10T22:55:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/78cd5920d35b29fd2a0fe894de8adf672ff52939d2e9b43cb83cd5ce1bc7/matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466", size = 9613040, upload-time = "2025-12-10T22:55:38.715Z" }, + { url = "https://files.pythonhosted.org/packages/30/4e/c10f171b6e2f44d9e3a2b96efa38b1677439d79c99357600a62cc1e9594e/matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf", size = 8142717, upload-time = "2025-12-10T22:55:41.103Z" }, + { url = "https://files.pythonhosted.org/packages/f1/76/934db220026b5fef85f45d51a738b91dea7d70207581063cd9bd8fafcf74/matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b", size = 8012751, upload-time = "2025-12-10T22:55:42.684Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b9/15fd5541ef4f5b9a17eefd379356cf12175fe577424e7b1d80676516031a/matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6", size = 8261076, upload-time = "2025-12-10T22:55:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a0/2ba3473c1b66b9c74dc7107c67e9008cb1782edbe896d4c899d39ae9cf78/matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1", size = 8148794, upload-time = "2025-12-10T22:55:46.252Z" }, + { url = "https://files.pythonhosted.org/packages/75/97/a471f1c3eb1fd6f6c24a31a5858f443891d5127e63a7788678d14e249aea/matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486", size = 8718474, upload-time = "2025-12-10T22:55:47.864Z" }, + { url = "https://files.pythonhosted.org/packages/01/be/cd478f4b66f48256f42927d0acbcd63a26a893136456cd079c0cc24fbabf/matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce", size = 9549637, upload-time = "2025-12-10T22:55:50.048Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8dc289776eae5109e268c4fb92baf870678dc048a25d4ac903683b86d5bf/matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6", size = 9613678, upload-time = "2025-12-10T22:55:52.21Z" }, + { url = "https://files.pythonhosted.org/packages/64/40/37612487cc8a437d4dd261b32ca21fe2d79510fe74af74e1f42becb1bdb8/matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149", size = 8142686, upload-time = "2025-12-10T22:55:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/66/52/8d8a8730e968185514680c2a6625943f70269509c3dcfc0dcf7d75928cb8/matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645", size = 8012917, upload-time = "2025-12-10T22:55:56.268Z" }, + { url = "https://files.pythonhosted.org/packages/b5/27/51fe26e1062f298af5ef66343d8ef460e090a27fea73036c76c35821df04/matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077", size = 8305679, upload-time = "2025-12-10T22:55:57.856Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/4de865bc591ac8e3062e835f42dd7fe7a93168d519557837f0e37513f629/matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22", size = 8198336, upload-time = "2025-12-10T22:55:59.371Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cb/2f7b6e75fb4dce87ef91f60cac4f6e34f4c145ab036a22318ec837971300/matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39", size = 8731653, upload-time = "2025-12-10T22:56:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/bd9c57d6ba670a37ab31fb87ec3e8691b947134b201f881665b28cc039ff/matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565", size = 9561356, upload-time = "2025-12-10T22:56:02.95Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3d/8b94a481456dfc9dfe6e39e93b5ab376e50998cddfd23f4ae3b431708f16/matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a", size = 9614000, upload-time = "2025-12-10T22:56:05.411Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cd/bc06149fe5585ba800b189a6a654a75f1f127e8aab02fd2be10df7fa500c/matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958", size = 8220043, upload-time = "2025-12-10T22:56:07.551Z" }, + { url = "https://files.pythonhosted.org/packages/e3/de/b22cf255abec916562cc04eef457c13e58a1990048de0c0c3604d082355e/matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5", size = 8062075, upload-time = "2025-12-10T22:56:09.178Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/9c0ff7a2f11615e516c3b058e1e6e8f9614ddeca53faca06da267c48345d/matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f", size = 8262481, upload-time = "2025-12-10T22:56:10.885Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ca/e8ae28649fcdf039fda5ef554b40a95f50592a3c47e6f7270c9561c12b07/matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b", size = 8151473, upload-time = "2025-12-10T22:56:12.377Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6f/009d129ae70b75e88cbe7e503a12a4c0670e08ed748a902c2568909e9eb5/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d", size = 9553896, upload-time = "2025-12-10T22:56:14.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/26/4221a741eb97967bc1fd5e4c52b9aa5a91b2f4ec05b59f6def4d820f9df9/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008", size = 9824193, upload-time = "2025-12-10T22:56:16.29Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/3abf75f38605772cf48a9daf5821cd4f563472f38b4b828c6fba6fa6d06e/matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c", size = 9615444, upload-time = "2025-12-10T22:56:18.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/a5/de89ac80f10b8dc615807ee1133cd99ac74082581196d4d9590bea10690d/matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11", size = 8272719, upload-time = "2025-12-10T22:56:20.366Z" }, + { url = "https://files.pythonhosted.org/packages/69/ce/b006495c19ccc0a137b48083168a37bd056392dee02f87dba0472f2797fe/matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8", size = 8144205, upload-time = "2025-12-10T22:56:22.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/d9/b31116a3a855bd313c6fcdb7226926d59b041f26061c6c5b1be66a08c826/matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50", size = 8305785, upload-time = "2025-12-10T22:56:24.218Z" }, + { url = "https://files.pythonhosted.org/packages/1e/90/6effe8103f0272685767ba5f094f453784057072f49b393e3ea178fe70a5/matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908", size = 8198361, upload-time = "2025-12-10T22:56:26.787Z" }, + { url = "https://files.pythonhosted.org/packages/d7/65/a73188711bea603615fc0baecca1061429ac16940e2385433cc778a9d8e7/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a", size = 9561357, upload-time = "2025-12-10T22:56:28.953Z" }, + { url = "https://files.pythonhosted.org/packages/f4/3d/b5c5d5d5be8ce63292567f0e2c43dde9953d3ed86ac2de0a72e93c8f07a1/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1", size = 9823610, upload-time = "2025-12-10T22:56:31.455Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011, upload-time = "2025-12-10T22:56:33.85Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801, upload-time = "2025-12-10T22:56:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560, upload-time = "2025-12-10T22:56:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/04/30/3afaa31c757f34b7725ab9d2ba8b48b5e89c2019c003e7d0ead143aabc5a/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6da7c2ce169267d0d066adcf63758f0604aa6c3eebf67458930f9d9b79ad1db1", size = 8249198, upload-time = "2025-12-10T22:56:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/48/2f/6334aec331f57485a642a7c8be03cb286f29111ae71c46c38b363230063c/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9153c3292705be9f9c64498a8872118540c3f4123d1a1c840172edf262c8be4a", size = 8136817, upload-time = "2025-12-10T22:56:47.339Z" }, + { url = "https://files.pythonhosted.org/packages/73/e4/6d6f14b2a759c622f191b2d67e9075a3f56aaccb3be4bb9bb6890030d0a0/matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2", size = 8713867, upload-time = "2025-12-10T22:56:48.954Z" }, +] + +[[package]] +name = "mcp" +version = "1.26.0" +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/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, +] + +[[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 = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload-time = "2025-10-06T14:52:30.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/9e/5c727587644d67b2ed479041e4b1c58e30afc011e3d45d25bbe35781217c/multidict-6.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4d409aa42a94c0b3fa617708ef5276dfe81012ba6753a0370fcc9d0195d0a1fc", size = 76604, upload-time = "2025-10-06T14:48:54.277Z" }, + { url = "https://files.pythonhosted.org/packages/17/e4/67b5c27bd17c085a5ea8f1ec05b8a3e5cba0ca734bfcad5560fb129e70ca/multidict-6.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:14c9e076eede3b54c636f8ce1c9c252b5f057c62131211f0ceeec273810c9721", size = 44715, upload-time = "2025-10-06T14:48:55.445Z" }, + { url = "https://files.pythonhosted.org/packages/4d/e1/866a5d77be6ea435711bef2a4291eed11032679b6b28b56b4776ab06ba3e/multidict-6.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c09703000a9d0fa3c3404b27041e574cc7f4df4c6563873246d0e11812a94b6", size = 44332, upload-time = "2025-10-06T14:48:56.706Z" }, + { url = "https://files.pythonhosted.org/packages/31/61/0c2d50241ada71ff61a79518db85ada85fdabfcf395d5968dae1cbda04e5/multidict-6.7.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a265acbb7bb33a3a2d626afbe756371dce0279e7b17f4f4eda406459c2b5ff1c", size = 245212, upload-time = "2025-10-06T14:48:58.042Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e0/919666a4e4b57fff1b57f279be1c9316e6cdc5de8a8b525d76f6598fefc7/multidict-6.7.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51cb455de290ae462593e5b1cb1118c5c22ea7f0d3620d9940bf695cea5a4bd7", size = 246671, upload-time = "2025-10-06T14:49:00.004Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cc/d027d9c5a520f3321b65adea289b965e7bcbd2c34402663f482648c716ce/multidict-6.7.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:db99677b4457c7a5c5a949353e125ba72d62b35f74e26da141530fbb012218a7", size = 225491, upload-time = "2025-10-06T14:49:01.393Z" }, + { url = "https://files.pythonhosted.org/packages/75/c4/bbd633980ce6155a28ff04e6a6492dd3335858394d7bb752d8b108708558/multidict-6.7.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f470f68adc395e0183b92a2f4689264d1ea4b40504a24d9882c27375e6662bb9", size = 257322, upload-time = "2025-10-06T14:49:02.745Z" }, + { url = "https://files.pythonhosted.org/packages/4c/6d/d622322d344f1f053eae47e033b0b3f965af01212de21b10bcf91be991fb/multidict-6.7.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0db4956f82723cc1c270de9c6e799b4c341d327762ec78ef82bb962f79cc07d8", size = 254694, upload-time = "2025-10-06T14:49:04.15Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9f/78f8761c2705d4c6d7516faed63c0ebdac569f6db1bef95e0d5218fdc146/multidict-6.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e56d780c238f9e1ae66a22d2adf8d16f485381878250db8d496623cd38b22bd", size = 246715, upload-time = "2025-10-06T14:49:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/950818e04f91b9c2b95aab3d923d9eabd01689d0dcd889563988e9ea0fd8/multidict-6.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d14baca2ee12c1a64740d4531356ba50b82543017f3ad6de0deb943c5979abb", size = 243189, upload-time = "2025-10-06T14:49:07.37Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3d/77c79e1934cad2ee74991840f8a0110966d9599b3af95964c0cd79bb905b/multidict-6.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:295a92a76188917c7f99cda95858c822f9e4aae5824246bba9b6b44004ddd0a6", size = 237845, upload-time = "2025-10-06T14:49:08.759Z" }, + { url = "https://files.pythonhosted.org/packages/63/1b/834ce32a0a97a3b70f86437f685f880136677ac00d8bce0027e9fd9c2db7/multidict-6.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39f1719f57adbb767ef592a50ae5ebb794220d1188f9ca93de471336401c34d2", size = 246374, upload-time = "2025-10-06T14:49:10.574Z" }, + { url = "https://files.pythonhosted.org/packages/23/ef/43d1c3ba205b5dec93dc97f3fba179dfa47910fc73aaaea4f7ceb41cec2a/multidict-6.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0a13fb8e748dfc94749f622de065dd5c1def7e0d2216dba72b1d8069a389c6ff", size = 253345, upload-time = "2025-10-06T14:49:12.331Z" }, + { url = "https://files.pythonhosted.org/packages/6b/03/eaf95bcc2d19ead522001f6a650ef32811aa9e3624ff0ad37c445c7a588c/multidict-6.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e3aa16de190d29a0ea1b48253c57d99a68492c8dd8948638073ab9e74dc9410b", size = 246940, upload-time = "2025-10-06T14:49:13.821Z" }, + { url = "https://files.pythonhosted.org/packages/e8/df/ec8a5fd66ea6cd6f525b1fcbb23511b033c3e9bc42b81384834ffa484a62/multidict-6.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a048ce45dcdaaf1defb76b2e684f997fb5abf74437b6cb7b22ddad934a964e34", size = 242229, upload-time = "2025-10-06T14:49:15.603Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a2/59b405d59fd39ec86d1142630e9049243015a5f5291ba49cadf3c090c541/multidict-6.7.0-cp311-cp311-win32.whl", hash = "sha256:a90af66facec4cebe4181b9e62a68be65e45ac9b52b67de9eec118701856e7ff", size = 41308, upload-time = "2025-10-06T14:49:16.871Z" }, + { url = "https://files.pythonhosted.org/packages/32/0f/13228f26f8b882c34da36efa776c3b7348455ec383bab4a66390e42963ae/multidict-6.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:95b5ffa4349df2887518bb839409bcf22caa72d82beec453216802f475b23c81", size = 46037, upload-time = "2025-10-06T14:49:18.457Z" }, + { url = "https://files.pythonhosted.org/packages/84/1f/68588e31b000535a3207fd3c909ebeec4fb36b52c442107499c18a896a2a/multidict-6.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:329aa225b085b6f004a4955271a7ba9f1087e39dcb7e65f6284a988264a63912", size = 43023, upload-time = "2025-10-06T14:49:19.648Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9e/9f61ac18d9c8b475889f32ccfa91c9f59363480613fc807b6e3023d6f60b/multidict-6.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8a3862568a36d26e650a19bb5cbbba14b71789032aebc0423f8cc5f150730184", size = 76877, upload-time = "2025-10-06T14:49:20.884Z" }, + { url = "https://files.pythonhosted.org/packages/38/6f/614f09a04e6184f8824268fce4bc925e9849edfa654ddd59f0b64508c595/multidict-6.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:960c60b5849b9b4f9dcc9bea6e3626143c252c74113df2c1540aebce70209b45", size = 45467, upload-time = "2025-10-06T14:49:22.054Z" }, + { url = "https://files.pythonhosted.org/packages/b3/93/c4f67a436dd026f2e780c433277fff72be79152894d9fc36f44569cab1a6/multidict-6.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2049be98fb57a31b4ccf870bf377af2504d4ae35646a19037ec271e4c07998aa", size = 43834, upload-time = "2025-10-06T14:49:23.566Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f5/013798161ca665e4a422afbc5e2d9e4070142a9ff8905e482139cd09e4d0/multidict-6.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0934f3843a1860dd465d38895c17fce1f1cb37295149ab05cd1b9a03afacb2a7", size = 250545, upload-time = "2025-10-06T14:49:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/71/2f/91dbac13e0ba94669ea5119ba267c9a832f0cb65419aca75549fcf09a3dc/multidict-6.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3e34f3a1b8131ba06f1a73adab24f30934d148afcd5f5de9a73565a4404384e", size = 258305, upload-time = "2025-10-06T14:49:26.778Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b0/754038b26f6e04488b48ac621f779c341338d78503fb45403755af2df477/multidict-6.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:efbb54e98446892590dc2458c19c10344ee9a883a79b5cec4bc34d6656e8d546", size = 242363, upload-time = "2025-10-06T14:49:28.562Z" }, + { url = "https://files.pythonhosted.org/packages/87/15/9da40b9336a7c9fa606c4cf2ed80a649dffeb42b905d4f63a1d7eb17d746/multidict-6.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a35c5fc61d4f51eb045061e7967cfe3123d622cd500e8868e7c0c592a09fedc4", size = 268375, upload-time = "2025-10-06T14:49:29.96Z" }, + { url = "https://files.pythonhosted.org/packages/82/72/c53fcade0cc94dfaad583105fd92b3a783af2091eddcb41a6d5a52474000/multidict-6.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29fe6740ebccba4175af1b9b87bf553e9c15cd5868ee967e010efcf94e4fd0f1", size = 269346, upload-time = "2025-10-06T14:49:31.404Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e2/9baffdae21a76f77ef8447f1a05a96ec4bc0a24dae08767abc0a2fe680b8/multidict-6.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:123e2a72e20537add2f33a79e605f6191fba2afda4cbb876e35c1a7074298a7d", size = 256107, upload-time = "2025-10-06T14:49:32.974Z" }, + { url = "https://files.pythonhosted.org/packages/3c/06/3f06f611087dc60d65ef775f1fb5aca7c6d61c6db4990e7cda0cef9b1651/multidict-6.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b284e319754366c1aee2267a2036248b24eeb17ecd5dc16022095e747f2f4304", size = 253592, upload-time = "2025-10-06T14:49:34.52Z" }, + { url = "https://files.pythonhosted.org/packages/20/24/54e804ec7945b6023b340c412ce9c3f81e91b3bf5fa5ce65558740141bee/multidict-6.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:803d685de7be4303b5a657b76e2f6d1240e7e0a8aa2968ad5811fa2285553a12", size = 251024, upload-time = "2025-10-06T14:49:35.956Z" }, + { url = "https://files.pythonhosted.org/packages/14/48/011cba467ea0b17ceb938315d219391d3e421dfd35928e5dbdc3f4ae76ef/multidict-6.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c04a328260dfd5db8c39538f999f02779012268f54614902d0afc775d44e0a62", size = 251484, upload-time = "2025-10-06T14:49:37.631Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2f/919258b43bb35b99fa127435cfb2d91798eb3a943396631ef43e3720dcf4/multidict-6.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8a19cdb57cd3df4cd865849d93ee14920fb97224300c88501f16ecfa2604b4e0", size = 263579, upload-time = "2025-10-06T14:49:39.502Z" }, + { url = "https://files.pythonhosted.org/packages/31/22/a0e884d86b5242b5a74cf08e876bdf299e413016b66e55511f7a804a366e/multidict-6.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b2fd74c52accced7e75de26023b7dccee62511a600e62311b918ec5c168fc2a", size = 259654, upload-time = "2025-10-06T14:49:41.32Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/17e10e1b5c5f5a40f2fcbb45953c9b215f8a4098003915e46a93f5fcaa8f/multidict-6.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e8bfdd0e487acf992407a140d2589fe598238eaeffa3da8448d63a63cd363f8", size = 251511, upload-time = "2025-10-06T14:49:46.021Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9a/201bb1e17e7af53139597069c375e7b0dcbd47594604f65c2d5359508566/multidict-6.7.0-cp312-cp312-win32.whl", hash = "sha256:dd32a49400a2c3d52088e120ee00c1e3576cbff7e10b98467962c74fdb762ed4", size = 41895, upload-time = "2025-10-06T14:49:48.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/e2/348cd32faad84eaf1d20cce80e2bb0ef8d312c55bca1f7fa9865e7770aaf/multidict-6.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:92abb658ef2d7ef22ac9f8bb88e8b6c3e571671534e029359b6d9e845923eb1b", size = 46073, upload-time = "2025-10-06T14:49:50.28Z" }, + { url = "https://files.pythonhosted.org/packages/25/ec/aad2613c1910dce907480e0c3aa306905830f25df2e54ccc9dea450cb5aa/multidict-6.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:490dab541a6a642ce1a9d61a4781656b346a55c13038f0b1244653828e3a83ec", size = 43226, upload-time = "2025-10-06T14:49:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/d2/86/33272a544eeb36d66e4d9a920602d1a2f57d4ebea4ef3cdfe5a912574c95/multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6", size = 76135, upload-time = "2025-10-06T14:49:54.26Z" }, + { url = "https://files.pythonhosted.org/packages/91/1c/eb97db117a1ebe46d457a3d235a7b9d2e6dcab174f42d1b67663dd9e5371/multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159", size = 45117, upload-time = "2025-10-06T14:49:55.82Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/6c3442322e41fb1dd4de8bd67bfd11cd72352ac131f6368315617de752f1/multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca", size = 43472, upload-time = "2025-10-06T14:49:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/75/3f/e2639e80325af0b6c6febdf8e57cc07043ff15f57fa1ef808f4ccb5ac4cd/multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8", size = 249342, upload-time = "2025-10-06T14:49:58.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/cc/84e0585f805cbeaa9cbdaa95f9a3d6aed745b9d25700623ac89a6ecff400/multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60", size = 257082, upload-time = "2025-10-06T14:49:59.89Z" }, + { url = "https://files.pythonhosted.org/packages/b0/9c/ac851c107c92289acbbf5cfb485694084690c1b17e555f44952c26ddc5bd/multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4", size = 240704, upload-time = "2025-10-06T14:50:01.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/cc/5f93e99427248c09da95b62d64b25748a5f5c98c7c2ab09825a1d6af0e15/multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f", size = 266355, upload-time = "2025-10-06T14:50:02.955Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0c/2ec1d883ceb79c6f7f6d7ad90c919c898f5d1c6ea96d322751420211e072/multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf", size = 267259, upload-time = "2025-10-06T14:50:04.446Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2d/f0b184fa88d6630aa267680bdb8623fb69cb0d024b8c6f0d23f9a0f406d3/multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32", size = 254903, upload-time = "2025-10-06T14:50:05.98Z" }, + { url = "https://files.pythonhosted.org/packages/06/c9/11ea263ad0df7dfabcad404feb3c0dd40b131bc7f232d5537f2fb1356951/multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036", size = 252365, upload-time = "2025-10-06T14:50:07.511Z" }, + { url = "https://files.pythonhosted.org/packages/41/88/d714b86ee2c17d6e09850c70c9d310abac3d808ab49dfa16b43aba9d53fd/multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec", size = 250062, upload-time = "2025-10-06T14:50:09.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/fe/ad407bb9e818c2b31383f6131ca19ea7e35ce93cf1310fce69f12e89de75/multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e", size = 249683, upload-time = "2025-10-06T14:50:10.714Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/a89abdb0229e533fb925e7c6e5c40201c2873efebc9abaf14046a4536ee6/multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64", size = 261254, upload-time = "2025-10-06T14:50:12.28Z" }, + { url = "https://files.pythonhosted.org/packages/8d/aa/0e2b27bd88b40a4fb8dc53dd74eecac70edaa4c1dd0707eb2164da3675b3/multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd", size = 257967, upload-time = "2025-10-06T14:50:14.16Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/0c67b7120d5d5f6d874ed85a085f9dc770a7f9d8813e80f44a9fec820bb7/multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288", size = 250085, upload-time = "2025-10-06T14:50:15.639Z" }, + { url = "https://files.pythonhosted.org/packages/ba/55/b73e1d624ea4b8fd4dd07a3bb70f6e4c7c6c5d9d640a41c6ffe5cdbd2a55/multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17", size = 41713, upload-time = "2025-10-06T14:50:17.066Z" }, + { url = "https://files.pythonhosted.org/packages/32/31/75c59e7d3b4205075b4c183fa4ca398a2daf2303ddf616b04ae6ef55cffe/multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390", size = 45915, upload-time = "2025-10-06T14:50:18.264Z" }, + { url = "https://files.pythonhosted.org/packages/31/2a/8987831e811f1184c22bc2e45844934385363ee61c0a2dcfa8f71b87e608/multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e", size = 43077, upload-time = "2025-10-06T14:50:19.853Z" }, + { url = "https://files.pythonhosted.org/packages/e8/68/7b3a5170a382a340147337b300b9eb25a9ddb573bcdfff19c0fa3f31ffba/multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00", size = 83114, upload-time = "2025-10-06T14:50:21.223Z" }, + { url = "https://files.pythonhosted.org/packages/55/5c/3fa2d07c84df4e302060f555bbf539310980362236ad49f50eeb0a1c1eb9/multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb", size = 48442, upload-time = "2025-10-06T14:50:22.871Z" }, + { url = "https://files.pythonhosted.org/packages/fc/56/67212d33239797f9bd91962bb899d72bb0f4c35a8652dcdb8ed049bef878/multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b", size = 46885, upload-time = "2025-10-06T14:50:24.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/d1/908f896224290350721597a61a69cd19b89ad8ee0ae1f38b3f5cd12ea2ac/multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c", size = 242588, upload-time = "2025-10-06T14:50:25.716Z" }, + { url = "https://files.pythonhosted.org/packages/ab/67/8604288bbd68680eee0ab568fdcb56171d8b23a01bcd5cb0c8fedf6e5d99/multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1", size = 249966, upload-time = "2025-10-06T14:50:28.192Z" }, + { url = "https://files.pythonhosted.org/packages/20/33/9228d76339f1ba51e3efef7da3ebd91964d3006217aae13211653193c3ff/multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b", size = 228618, upload-time = "2025-10-06T14:50:29.82Z" }, + { url = "https://files.pythonhosted.org/packages/f8/2d/25d9b566d10cab1c42b3b9e5b11ef79c9111eaf4463b8c257a3bd89e0ead/multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5", size = 257539, upload-time = "2025-10-06T14:50:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b1/8d1a965e6637fc33de3c0d8f414485c2b7e4af00f42cab3d84e7b955c222/multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad", size = 256345, upload-time = "2025-10-06T14:50:33.26Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0c/06b5a8adbdeedada6f4fb8d8f193d44a347223b11939b42953eeb6530b6b/multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c", size = 247934, upload-time = "2025-10-06T14:50:34.808Z" }, + { url = "https://files.pythonhosted.org/packages/8f/31/b2491b5fe167ca044c6eb4b8f2c9f3b8a00b24c432c365358eadac5d7625/multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5", size = 245243, upload-time = "2025-10-06T14:50:36.436Z" }, + { url = "https://files.pythonhosted.org/packages/61/1a/982913957cb90406c8c94f53001abd9eafc271cb3e70ff6371590bec478e/multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10", size = 235878, upload-time = "2025-10-06T14:50:37.953Z" }, + { url = "https://files.pythonhosted.org/packages/be/c0/21435d804c1a1cf7a2608593f4d19bca5bcbd7a81a70b253fdd1c12af9c0/multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754", size = 243452, upload-time = "2025-10-06T14:50:39.574Z" }, + { url = "https://files.pythonhosted.org/packages/54/0a/4349d540d4a883863191be6eb9a928846d4ec0ea007d3dcd36323bb058ac/multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c", size = 252312, upload-time = "2025-10-06T14:50:41.612Z" }, + { url = "https://files.pythonhosted.org/packages/26/64/d5416038dbda1488daf16b676e4dbfd9674dde10a0cc8f4fc2b502d8125d/multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762", size = 246935, upload-time = "2025-10-06T14:50:43.972Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8c/8290c50d14e49f35e0bd4abc25e1bc7711149ca9588ab7d04f886cdf03d9/multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6", size = 243385, upload-time = "2025-10-06T14:50:45.648Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a0/f83ae75e42d694b3fbad3e047670e511c138be747bc713cf1b10d5096416/multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d", size = 47777, upload-time = "2025-10-06T14:50:47.154Z" }, + { url = "https://files.pythonhosted.org/packages/dc/80/9b174a92814a3830b7357307a792300f42c9e94664b01dee8e457551fa66/multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6", size = 53104, upload-time = "2025-10-06T14:50:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/cc/28/04baeaf0428d95bb7a7bea0e691ba2f31394338ba424fb0679a9ed0f4c09/multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792", size = 45503, upload-time = "2025-10-06T14:50:50.16Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b1/3da6934455dd4b261d4c72f897e3a5728eba81db59959f3a639245891baa/multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842", size = 75128, upload-time = "2025-10-06T14:50:51.92Z" }, + { url = "https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b", size = 44410, upload-time = "2025-10-06T14:50:53.275Z" }, + { url = "https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38", size = 43205, upload-time = "2025-10-06T14:50:54.911Z" }, + { url = "https://files.pythonhosted.org/packages/02/68/6b086fef8a3f1a8541b9236c594f0c9245617c29841f2e0395d979485cde/multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128", size = 245084, upload-time = "2025-10-06T14:50:56.369Z" }, + { url = "https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34", size = 252667, upload-time = "2025-10-06T14:50:57.991Z" }, + { url = "https://files.pythonhosted.org/packages/02/a5/eeb3f43ab45878f1895118c3ef157a480db58ede3f248e29b5354139c2c9/multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99", size = 233590, upload-time = "2025-10-06T14:50:59.589Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/76d02f8270b97269d7e3dbd45644b1785bda457b474315f8cf999525a193/multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202", size = 264112, upload-time = "2025-10-06T14:51:01.183Z" }, + { url = "https://files.pythonhosted.org/packages/76/0b/c28a70ecb58963847c2a8efe334904cd254812b10e535aefb3bcce513918/multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1", size = 261194, upload-time = "2025-10-06T14:51:02.794Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3", size = 248510, upload-time = "2025-10-06T14:51:04.724Z" }, + { url = "https://files.pythonhosted.org/packages/93/cd/06c1fa8282af1d1c46fd55c10a7930af652afdce43999501d4d68664170c/multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d", size = 248395, upload-time = "2025-10-06T14:51:06.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/ac/82cb419dd6b04ccf9e7e61befc00c77614fc8134362488b553402ecd55ce/multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6", size = 239520, upload-time = "2025-10-06T14:51:08.091Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f3/a0f9bf09493421bd8716a362e0cd1d244f5a6550f5beffdd6b47e885b331/multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7", size = 245479, upload-time = "2025-10-06T14:51:10.365Z" }, + { url = "https://files.pythonhosted.org/packages/8d/01/476d38fc73a212843f43c852b0eee266b6971f0e28329c2184a8df90c376/multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb", size = 258903, upload-time = "2025-10-06T14:51:12.466Z" }, + { url = "https://files.pythonhosted.org/packages/49/6d/23faeb0868adba613b817d0e69c5f15531b24d462af8012c4f6de4fa8dc3/multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f", size = 252333, upload-time = "2025-10-06T14:51:14.48Z" }, + { url = "https://files.pythonhosted.org/packages/1e/cc/48d02ac22b30fa247f7dad82866e4b1015431092f4ba6ebc7e77596e0b18/multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f", size = 243411, upload-time = "2025-10-06T14:51:16.072Z" }, + { url = "https://files.pythonhosted.org/packages/4a/03/29a8bf5a18abf1fe34535c88adbdfa88c9fb869b5a3b120692c64abe8284/multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885", size = 40940, upload-time = "2025-10-06T14:51:17.544Z" }, + { url = "https://files.pythonhosted.org/packages/82/16/7ed27b680791b939de138f906d5cf2b4657b0d45ca6f5dd6236fdddafb1a/multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c", size = 45087, upload-time = "2025-10-06T14:51:18.875Z" }, + { url = "https://files.pythonhosted.org/packages/cd/3c/e3e62eb35a1950292fe39315d3c89941e30a9d07d5d2df42965ab041da43/multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000", size = 42368, upload-time = "2025-10-06T14:51:20.225Z" }, + { url = "https://files.pythonhosted.org/packages/8b/40/cd499bd0dbc5f1136726db3153042a735fffd0d77268e2ee20d5f33c010f/multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63", size = 82326, upload-time = "2025-10-06T14:51:21.588Z" }, + { url = "https://files.pythonhosted.org/packages/13/8a/18e031eca251c8df76daf0288e6790561806e439f5ce99a170b4af30676b/multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718", size = 48065, upload-time = "2025-10-06T14:51:22.93Z" }, + { url = "https://files.pythonhosted.org/packages/40/71/5e6701277470a87d234e433fb0a3a7deaf3bcd92566e421e7ae9776319de/multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2", size = 46475, upload-time = "2025-10-06T14:51:24.352Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6a/bab00cbab6d9cfb57afe1663318f72ec28289ea03fd4e8236bb78429893a/multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e", size = 239324, upload-time = "2025-10-06T14:51:25.822Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5f/8de95f629fc22a7769ade8b41028e3e5a822c1f8904f618d175945a81ad3/multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064", size = 246877, upload-time = "2025-10-06T14:51:27.604Z" }, + { url = "https://files.pythonhosted.org/packages/23/b4/38881a960458f25b89e9f4a4fdcb02ac101cfa710190db6e5528841e67de/multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e", size = 225824, upload-time = "2025-10-06T14:51:29.664Z" }, + { url = "https://files.pythonhosted.org/packages/1e/39/6566210c83f8a261575f18e7144736059f0c460b362e96e9cf797a24b8e7/multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd", size = 253558, upload-time = "2025-10-06T14:51:31.684Z" }, + { url = "https://files.pythonhosted.org/packages/00/a3/67f18315100f64c269f46e6c0319fa87ba68f0f64f2b8e7fd7c72b913a0b/multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a", size = 252339, upload-time = "2025-10-06T14:51:33.699Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2a/1cb77266afee2458d82f50da41beba02159b1d6b1f7973afc9a1cad1499b/multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96", size = 244895, upload-time = "2025-10-06T14:51:36.189Z" }, + { url = "https://files.pythonhosted.org/packages/dd/72/09fa7dd487f119b2eb9524946ddd36e2067c08510576d43ff68469563b3b/multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e", size = 241862, upload-time = "2025-10-06T14:51:41.291Z" }, + { url = "https://files.pythonhosted.org/packages/65/92/bc1f8bd0853d8669300f732c801974dfc3702c3eeadae2f60cef54dc69d7/multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599", size = 232376, upload-time = "2025-10-06T14:51:43.55Z" }, + { url = "https://files.pythonhosted.org/packages/09/86/ac39399e5cb9d0c2ac8ef6e10a768e4d3bc933ac808d49c41f9dc23337eb/multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394", size = 240272, upload-time = "2025-10-06T14:51:45.265Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b6/fed5ac6b8563ec72df6cb1ea8dac6d17f0a4a1f65045f66b6d3bf1497c02/multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38", size = 248774, upload-time = "2025-10-06T14:51:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8d/b954d8c0dc132b68f760aefd45870978deec6818897389dace00fcde32ff/multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9", size = 242731, upload-time = "2025-10-06T14:51:48.541Z" }, + { url = "https://files.pythonhosted.org/packages/16/9d/a2dac7009125d3540c2f54e194829ea18ac53716c61b655d8ed300120b0f/multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0", size = 240193, upload-time = "2025-10-06T14:51:50.355Z" }, + { url = "https://files.pythonhosted.org/packages/39/ca/c05f144128ea232ae2178b008d5011d4e2cea86e4ee8c85c2631b1b94802/multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13", size = 48023, upload-time = "2025-10-06T14:51:51.883Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8f/0a60e501584145588be1af5cc829265701ba3c35a64aec8e07cbb71d39bb/multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd", size = 53507, upload-time = "2025-10-06T14:51:53.672Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/3148b988a9c6239903e786eac19c889fab607c31d6efa7fb2147e5680f23/multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827", size = 44804, upload-time = "2025-10-06T14:51:55.415Z" }, + { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "nicegui" +version = "3.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "aiohttp" }, + { name = "certifi" }, + { name = "docutils" }, + { name = "fastapi" }, + { name = "h11" }, + { name = "httpx" }, + { name = "ifaddr" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markdown2" }, + { name = "orjson", marker = "platform_machine != 'i386' and platform_machine != 'i686' and platform_python_implementation != 'PyPy'" }, + { name = "pydantic-core" }, + { name = "pygments" }, + { name = "python-engineio" }, + { name = "python-multipart" }, + { name = "python-socketio", extra = ["asyncio-client"] }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "uvicorn", extra = ["standard"] }, + { name = "watchfiles" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/76/0fe2a54f0ba6141747fc1413ffd1657dd809bf5c8105f6832628d9b5f86e/nicegui-3.7.1.tar.gz", hash = "sha256:dc6ef68083ce15d92848e91908eb5313962dc0cd8a3350536b18c425ad5d5ca5", size = 21343701, upload-time = "2026-02-05T14:39:21.818Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a8/91f47b20368bdfb4c818416df0d5cbd867a07364d05cff56d095d248b81e/nicegui-3.7.1-py3-none-any.whl", hash = "sha256:202c39b415eb6aa08cc74b29cadc8b9357c5ba1bf67840c989437caf111c54b4", size = 22008333, upload-time = "2026-02-05T14:39:17.209Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/44/71852273146957899753e69986246d6a176061ea183407e95418c2aa4d9a/numpy-2.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7e88598032542bd49af7c4747541422884219056c268823ef6e5e89851c8825", size = 16955478, upload-time = "2026-01-31T23:10:25.623Z" }, + { url = "https://files.pythonhosted.org/packages/74/41/5d17d4058bd0cd96bcbd4d9ff0fb2e21f52702aab9a72e4a594efa18692f/numpy-2.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7edc794af8b36ca37ef5fcb5e0d128c7e0595c7b96a2318d1badb6fcd8ee86b1", size = 14965467, upload-time = "2026-01-31T23:10:28.186Z" }, + { url = "https://files.pythonhosted.org/packages/49/48/fb1ce8136c19452ed15f033f8aee91d5defe515094e330ce368a0647846f/numpy-2.4.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:6e9f61981ace1360e42737e2bae58b27bf28a1b27e781721047d84bd754d32e7", size = 5475172, upload-time = "2026-01-31T23:10:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/40/a9/3feb49f17bbd1300dd2570432961f5c8a4ffeff1db6f02c7273bd020a4c9/numpy-2.4.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cb7bbb88aa74908950d979eeaa24dbdf1a865e3c7e45ff0121d8f70387b55f73", size = 6805145, upload-time = "2026-01-31T23:10:32.352Z" }, + { url = "https://files.pythonhosted.org/packages/3f/39/fdf35cbd6d6e2fcad42fcf85ac04a85a0d0fbfbf34b30721c98d602fd70a/numpy-2.4.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f069069931240b3fc703f1e23df63443dbd6390614c8c44a87d96cd0ec81eb1", size = 15966084, upload-time = "2026-01-31T23:10:34.502Z" }, + { url = "https://files.pythonhosted.org/packages/1b/46/6fa4ea94f1ddf969b2ee941290cca6f1bfac92b53c76ae5f44afe17ceb69/numpy-2.4.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c02ef4401a506fb60b411467ad501e1429a3487abca4664871d9ae0b46c8ba32", size = 16899477, upload-time = "2026-01-31T23:10:37.075Z" }, + { url = "https://files.pythonhosted.org/packages/09/a1/2a424e162b1a14a5bd860a464ab4e07513916a64ab1683fae262f735ccd2/numpy-2.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2653de5c24910e49c2b106499803124dde62a5a1fe0eedeaecf4309a5f639390", size = 17323429, upload-time = "2026-01-31T23:10:39.704Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a2/73014149ff250628df72c58204822ac01d768697913881aacf839ff78680/numpy-2.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1ae241bbfc6ae276f94a170b14785e561cb5e7f626b6688cf076af4110887413", size = 18635109, upload-time = "2026-01-31T23:10:41.924Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0c/73e8be2f1accd56df74abc1c5e18527822067dced5ec0861b5bb882c2ce0/numpy-2.4.2-cp311-cp311-win32.whl", hash = "sha256:df1b10187212b198dd45fa943d8985a3c8cf854aed4923796e0e019e113a1bda", size = 6237915, upload-time = "2026-01-31T23:10:45.26Z" }, + { url = "https://files.pythonhosted.org/packages/76/ae/e0265e0163cf127c24c3969d29f1c4c64551a1e375d95a13d32eab25d364/numpy-2.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:b9c618d56a29c9cb1c4da979e9899be7578d2e0b3c24d52079c166324c9e8695", size = 12607972, upload-time = "2026-01-31T23:10:47.021Z" }, + { url = "https://files.pythonhosted.org/packages/29/a5/c43029af9b8014d6ea157f192652c50042e8911f4300f8f6ed3336bf437f/numpy-2.4.2-cp311-cp311-win_arm64.whl", hash = "sha256:47c5a6ed21d9452b10227e5e8a0e1c22979811cad7dcc19d8e3e2fb8fa03f1a3", size = 10485763, upload-time = "2026-01-31T23:10:50.087Z" }, + { url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963, upload-time = "2026-01-31T23:10:52.147Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571, upload-time = "2026-01-31T23:10:54.789Z" }, + { url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469, upload-time = "2026-01-31T23:10:57.343Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820, upload-time = "2026-01-31T23:10:59.429Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067, upload-time = "2026-01-31T23:11:01.291Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782, upload-time = "2026-01-31T23:11:03.669Z" }, + { url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128, upload-time = "2026-01-31T23:11:05.913Z" }, + { url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324, upload-time = "2026-01-31T23:11:08.248Z" }, + { url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282, upload-time = "2026-01-31T23:11:10.497Z" }, + { url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210, upload-time = "2026-01-31T23:11:12.176Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" }, + { url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" }, + { url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" }, + { url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" }, + { url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" }, + { url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" }, + { url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" }, + { url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" }, + { url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" }, + { url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" }, + { url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" }, + { url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" }, + { url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" }, + { url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" }, + { url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" }, + { url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" }, + { url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" }, + { url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" }, + { url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" }, + { url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" }, + { url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" }, + { url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" }, + { url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f8/50e14d36d915ef64d8f8bc4a087fc8264d82c785eda6711f80ab7e620335/numpy-2.4.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:89f7268c009bc492f506abd6f5265defa7cb3f7487dc21d357c3d290add45082", size = 16833179, upload-time = "2026-01-31T23:12:53.5Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/809b5cad63812058a8189e91a1e2d55a5a18fd04611dbad244e8aeae465c/numpy-2.4.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6dee3bb76aa4009d5a912180bf5b2de012532998d094acee25d9cb8dee3e44a", size = 14889755, upload-time = "2026-01-31T23:12:55.933Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ea/181b9bcf7627fc8371720316c24db888dcb9829b1c0270abf3d288b2e29b/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:cd2bd2bbed13e213d6b55dc1d035a4f91748a7d3edc9480c13898b0353708920", size = 5399500, upload-time = "2026-01-31T23:12:58.671Z" }, + { url = "https://files.pythonhosted.org/packages/33/9f/413adf3fc955541ff5536b78fcf0754680b3c6d95103230252a2c9408d23/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:cf28c0c1d4c4bf00f509fa7eb02c58d7caf221b50b467bcb0d9bbf1584d5c821", size = 6714252, upload-time = "2026-01-31T23:13:00.518Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/643aad274e29ccbdf42ecd94dafe524b81c87bcb56b83872d54827f10543/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e04ae107ac591763a47398bb45b568fc38f02dbc4aa44c063f67a131f99346cb", size = 15797142, upload-time = "2026-01-31T23:13:02.219Z" }, + { url = "https://files.pythonhosted.org/packages/66/27/965b8525e9cb5dc16481b30a1b3c21e50c7ebf6e9dbd48d0c4d0d5089c7e/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:602f65afdef699cda27ec0b9224ae5dc43e328f4c24c689deaf77133dbee74d0", size = 16727979, upload-time = "2026-01-31T23:13:04.62Z" }, + { url = "https://files.pythonhosted.org/packages/de/e5/b7d20451657664b07986c2f6e3be564433f5dcaf3482d68eaecd79afaf03/numpy-2.4.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be71bf1edb48ebbbf7f6337b5bfd2f895d1902f6335a5830b20141fc126ffba0", size = 12502577, upload-time = "2026-01-31T23:13:07.08Z" }, +] + +[[package]] +name = "nvidia-cublas" +version = "13.1.0.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/a5/fce49e2ae977e0ccc084e5adafceb4f0ac0c8333cb6863501618a7277f67/nvidia_cublas-13.1.0.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:c86fc7f7ae36d7528288c5d88098edcb7b02c633d262e7ddbb86b0ad91be5df2", size = 542851226, upload-time = "2025-10-09T08:59:04.818Z" }, + { url = "https://files.pythonhosted.org/packages/e7/44/423ac00af4dd95a5aeb27207e2c0d9b7118702149bf4704c3ddb55bb7429/nvidia_cublas-13.1.0.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:ee8722c1f0145ab246bccb9e452153b5e0515fd094c3678df50b2a0888b8b171", size = 423133236, upload-time = "2025-10-09T08:59:32.536Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2a/80353b103fc20ce05ef51e928daed4b6015db4aaa9162ed0997090fe2250/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151", size = 10310827, upload-time = "2025-09-04T08:26:42.012Z" }, + { url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8", size = 10715597, upload-time = "2025-09-04T08:26:51.312Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200, upload-time = "2025-09-04T08:28:44.204Z" }, + { url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449, upload-time = "2025-09-04T08:28:20.239Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime" +version = "13.0.96" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/4f/17d7b9b8e285199c58ce28e31b5c5bbaa4d8271af06a89b6405258245de2/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55", size = 2261060, upload-time = "2025-10-09T08:55:15.78Z" }, + { url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548", size = 2243632, upload-time = "2025-10-09T08:55:36.117Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu13" +version = "9.19.0.56" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/84/26025437c1e6b61a707442184fa0c03d083b661adf3a3eecfd6d21677740/nvidia_cudnn_cu13-9.19.0.56-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:6ed29ffaee1176c612daf442e4dd6cfeb6a0caa43ddcbeb59da94953030b1be4", size = 433781201, upload-time = "2026-02-03T20:40:53.805Z" }, + { url = "https://files.pythonhosted.org/packages/a3/22/0b4b932655d17a6da1b92fa92ab12844b053bb2ac2475e179ba6f043da1e/nvidia_cudnn_cu13-9.19.0.56-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:d20e1734305e9d68889a96e3f35094d733ff1f83932ebe462753973e53a572bf", size = 366066321, upload-time = "2026-02-03T20:44:52.837Z" }, +] + +[[package]] +name = "nvidia-cufft" +version = "12.0.0.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3", size = 214085489, upload-time = "2025-09-04T08:31:56.044Z" }, +] + +[[package]] +name = "nvidia-cufile" +version = "1.15.1.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44", size = 1223672, upload-time = "2025-09-04T08:32:22.779Z" }, + { url = "https://files.pythonhosted.org/packages/ab/73/cc4a14c9813a8a0d509417cf5f4bdaba76e924d58beb9864f5a7baceefbf/nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1", size = 1136992, upload-time = "2025-09-04T08:32:14.119Z" }, +] + +[[package]] +name = "nvidia-curand" +version = "10.4.0.35" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/72/7c2ae24fb6b63a32e6ae5d241cc65263ea18d08802aaae087d9f013335a2/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a", size = 61962106, upload-time = "2025-08-04T10:21:41.128Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc", size = 59544258, upload-time = "2025-08-04T10:22:03.992Z" }, +] + +[[package]] +name = "nvidia-cusolver" +version = "12.0.4.66" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cusparse", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112", size = 200941980, upload-time = "2025-09-04T08:33:22.767Z" }, +] + +[[package]] +name = "nvidia-cusparse" +version = "12.6.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, + { url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b", size = 145942937, upload-time = "2025-09-04T08:33:58.029Z" }, +] + +[[package]] +name = "nvidia-cusparselt-cu13" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/10/8dcd1175260706a2fc92a16a52e306b71d4c1ea0b0cc4a9484183399818a/nvidia_cusparselt_cu13-0.8.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:400c6ed1cf6780fc6efedd64ec9f1345871767e6a1a0a552a1ea0578117ea77c", size = 220791277, upload-time = "2025-08-13T19:22:40.982Z" }, + { url = "https://files.pythonhosted.org/packages/fd/53/43b0d71f4e702fa9733f8b4571fdca50a8813f1e450b656c239beff12315/nvidia_cusparselt_cu13-0.8.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:25e30a8a7323935d4ad0340b95a0b69926eee755767e8e0b1cf8dd85b197d3fd", size = 169884119, upload-time = "2025-08-13T19:23:41.967Z" }, +] + +[[package]] +name = "nvidia-nccl-cu13" +version = "2.28.9" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/55/1920646a2e43ffd4fc958536b276197ed740e9e0c54105b4bb3521591fc7/nvidia_nccl_cu13-2.28.9-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:01c873ba1626b54caa12272ed228dc5b2781545e0ae8ba3f432a8ef1c6d78643", size = 196561677, upload-time = "2025-11-18T05:49:03.45Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b4/878fefaad5b2bcc6fcf8d474a25e3e3774bc5133e4b58adff4d0bca238bc/nvidia_nccl_cu13-2.28.9-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:e4553a30f34195f3fa1da02a6da3d6337d28f2003943aa0a3d247bbc25fefc42", size = 196493177, upload-time = "2025-11-18T05:49:17.677Z" }, +] + +[[package]] +name = "nvidia-nvjitlink" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/7a/123e033aaff487c77107195fa5a2b8686795ca537935a24efae476c41f05/nvidia_nvjitlink-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:13a74f429e23b921c1109976abefacc69835f2f433ebd323d3946e11d804e47b", size = 40713933, upload-time = "2025-09-04T08:35:43.553Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2c/93c5250e64df4f894f1cbb397c6fd71f79813f9fd79d7cd61de3f97b3c2d/nvidia_nvjitlink-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e931536ccc7d467a98ba1d8b89ff7fa7f1fa3b13f2b0069118cd7f47bff07d0c", size = 38768748, upload-time = "2025-09-04T08:35:20.008Z" }, +] + +[[package]] +name = "nvidia-nvshmem-cu13" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/0f/05cc9c720236dcd2db9c1ab97fff629e96821be2e63103569da0c9b72f19/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9", size = 60215947, upload-time = "2025-09-06T00:32:20.022Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80", size = 60412546, upload-time = "2025-09-06T00:32:41.564Z" }, +] + +[[package]] +name = "nvidia-nvtx" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4", size = 148047, upload-time = "2025-09-04T08:29:01.761Z" }, + { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" }, +] + +[[package]] +name = "openai" +version = "2.8.1" +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/d5/e4/42591e356f1d53c568418dc7e30dcda7be31dd5a4d570bca22acb0525862/openai-2.8.1.tar.gz", hash = "sha256:cb1b79eef6e809f6da326a7ef6038719e35aa944c42d081807bfa1be8060f15f", size = 602490, upload-time = "2025-11-17T22:39:59.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/4f/dbc0c124c40cb390508a82770fb9f6e3ed162560181a85089191a851c59a/openai-2.8.1-py3-none-any.whl", hash = "sha256:c6c3b5a04994734386e8dad3c00a393f56d3b68a27cd2e8acae91a59e4122463", size = 1022688, upload-time = "2025-11-17T22:39:57.675Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/d8/0f354c375628e048bd0570645b310797299754730079853095bf000fba69/opentelemetry_api-1.38.0.tar.gz", hash = "sha256:f4c193b5e8acb0912b06ac5b16321908dd0843d75049c091487322284a3eea12", size = 65242, upload-time = "2025-10-16T08:35:50.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/a2/d86e01c28300bd41bab8f18afd613676e2bd63515417b77636fc1add426f/opentelemetry_api-1.38.0-py3-none-any.whl", hash = "sha256:2891b0197f47124454ab9f0cf58f3be33faca394457ac3e09daba13ff50aa582", size = 65947, upload-time = "2025-10-16T08:35:30.23Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/83/dd4660f2956ff88ed071e9e0e36e830df14b8c5dc06722dbde1841accbe8/opentelemetry_exporter_otlp_proto_common-1.38.0.tar.gz", hash = "sha256:e333278afab4695aa8114eeb7bf4e44e65c6607d54968271a249c180b2cb605c", size = 20431, upload-time = "2025-10-16T08:35:53.285Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/9e/55a41c9601191e8cd8eb626b54ee6827b9c9d4a46d736f32abc80d8039fc/opentelemetry_exporter_otlp_proto_common-1.38.0-py3-none-any.whl", hash = "sha256:03cb76ab213300fe4f4c62b7d8f17d97fcfd21b89f0b5ce38ea156327ddda74a", size = 18359, upload-time = "2025-10-16T08:35:34.099Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/c0/43222f5b97dc10812bc4f0abc5dc7cd0a2525a91b5151d26c9e2e958f52e/opentelemetry_exporter_otlp_proto_grpc-1.38.0.tar.gz", hash = "sha256:2473935e9eac71f401de6101d37d6f3f0f1831db92b953c7dcc912536158ebd6", size = 24676, upload-time = "2025-10-16T08:35:53.83Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/f0/bd831afbdba74ca2ce3982142a2fad707f8c487e8a3b6fef01f1d5945d1b/opentelemetry_exporter_otlp_proto_grpc-1.38.0-py3-none-any.whl", hash = "sha256:7c49fd9b4bd0dbe9ba13d91f764c2d20b0025649a6e4ac35792fb8d84d764bc7", size = 19695, upload-time = "2025-10-16T08:35:35.053Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/0a/debcdfb029fbd1ccd1563f7c287b89a6f7bef3b2902ade56797bfd020854/opentelemetry_exporter_otlp_proto_http-1.38.0.tar.gz", hash = "sha256:f16bd44baf15cbe07633c5112ffc68229d0edbeac7b37610be0b2def4e21e90b", size = 17282, upload-time = "2025-10-16T08:35:54.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/77/154004c99fb9f291f74aa0822a2f5bbf565a72d8126b3a1b63ed8e5f83c7/opentelemetry_exporter_otlp_proto_http-1.38.0-py3-none-any.whl", hash = "sha256:84b937305edfc563f08ec69b9cb2298be8188371217e867c1854d77198d0825b", size = 19579, upload-time = "2025-10-16T08:35:36.269Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/14/f0c4f0f6371b9cb7f9fa9ee8918bfd59ac7040c7791f1e6da32a1839780d/opentelemetry_proto-1.38.0.tar.gz", hash = "sha256:88b161e89d9d372ce723da289b7da74c3a8354a8e5359992be813942969ed468", size = 46152, upload-time = "2025-10-16T08:36:01.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/6a/82b68b14efca5150b2632f3692d627afa76b77378c4999f2648979409528/opentelemetry_proto-1.38.0-py3-none-any.whl", hash = "sha256:b6ebe54d3217c42e45462e2a1ae28c3e2bf2ec5a5645236a490f55f45f1a0a18", size = 72535, upload-time = "2025-10-16T08:35:45.749Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/cb/f0eee1445161faf4c9af3ba7b848cc22a50a3d3e2515051ad8628c35ff80/opentelemetry_sdk-1.38.0.tar.gz", hash = "sha256:93df5d4d871ed09cb4272305be4d996236eedb232253e3ab864c8620f051cebe", size = 171942, upload-time = "2025-10-16T08:36:02.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/2e/e93777a95d7d9c40d270a371392b6d6f1ff170c2a3cb32d6176741b5b723/opentelemetry_sdk-1.38.0-py3-none-any.whl", hash = "sha256:1c66af6564ecc1553d72d811a01df063ff097cdc82ce188da9951f93b8d10f6b", size = 132349, upload-time = "2025-10-16T08:35:46.995Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.59b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/bc/8b9ad3802cd8ac6583a4eb7de7e5d7db004e89cb7efe7008f9c8a537ee75/opentelemetry_semantic_conventions-0.59b0.tar.gz", hash = "sha256:7a6db3f30d70202d5bf9fa4b69bc866ca6a30437287de6c510fb594878aed6b0", size = 129861, upload-time = "2025-10-16T08:36:03.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/7d/c88d7b15ba8fe5c6b8f93be50fc11795e9fc05386c44afaf6b76fe191f9b/opentelemetry_semantic_conventions-0.59b0-py3-none-any.whl", hash = "sha256:35d3b8833ef97d614136e253c1da9342b4c3c083bbaf29ce31d572a1c3825eed", size = 207954, upload-time = "2025-10-16T08:35:48.054Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions-ai" +version = "0.4.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-sdk" }, + { name = "opentelemetry-semantic-conventions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/75/455c15f8360b475dd31101a87eab316420388486f7941bf019cbf4e63d5b/opentelemetry_semantic_conventions_ai-0.4.15.tar.gz", hash = "sha256:12de172d1e11d21c6e82bbf578c7e8a713589a7fda76af9ed785632564a28b81", size = 18595, upload-time = "2026-03-02T15:36:50.254Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/49/819fb212386f77cfd93f81bd916d674f0e735f87c8ac2262ed14e3b852c2/opentelemetry_semantic_conventions_ai-0.4.15-py3-none-any.whl", hash = "sha256:011461f1fba30f27035c49ab3b8344367adc72da0a6c8d3c7428303c6779edc9", size = 5999, upload-time = "2026-03-02T15:36:51.44Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/02/da6cb01fc6087048d7f61522c327edf4250f1683a58a839fdcc435746dd5/orjson-3.11.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9487abc2c2086e7c8eb9a211d2ce8855bae0e92586279d0d27b341d5ad76c85c", size = 228664, upload-time = "2026-02-02T15:37:25.542Z" }, + { url = "https://files.pythonhosted.org/packages/c1/c2/5885e7a5881dba9a9af51bc564e8967225a642b3e03d089289a35054e749/orjson-3.11.7-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:79cacb0b52f6004caf92405a7e1f11e6e2de8bdf9019e4f76b44ba045125cd6b", size = 125344, upload-time = "2026-02-02T15:37:26.92Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1d/4e7688de0a92d1caf600dfd5fb70b4c5bfff51dfa61ac555072ef2d0d32a/orjson-3.11.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2e85fe4698b6a56d5e2ebf7ae87544d668eb6bde1ad1226c13f44663f20ec9e", size = 128404, upload-time = "2026-02-02T15:37:28.108Z" }, + { url = "https://files.pythonhosted.org/packages/2f/b2/ec04b74ae03a125db7bd69cffd014b227b7f341e3261bf75b5eb88a1aa92/orjson-3.11.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b8d14b71c0b12963fe8a62aac87119f1afdf4cb88a400f61ca5ae581449efcb5", size = 123677, upload-time = "2026-02-02T15:37:30.287Z" }, + { url = "https://files.pythonhosted.org/packages/4c/69/f95bdf960605f08f827f6e3291fe243d8aa9c5c9ff017a8d7232209184c3/orjson-3.11.7-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91c81ef070c8f3220054115e1ef468b1c9ce8497b4e526cb9f68ab4dc0a7ac62", size = 128950, upload-time = "2026-02-02T15:37:31.595Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1b/de59c57bae1d148ef298852abd31909ac3089cff370dfd4cd84cc99cbc42/orjson-3.11.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:411ebaf34d735e25e358a6d9e7978954a9c9d58cfb47bc6683cdc3964cd2f910", size = 141756, upload-time = "2026-02-02T15:37:32.985Z" }, + { url = "https://files.pythonhosted.org/packages/ee/9e/9decc59f4499f695f65c650f6cfa6cd4c37a3fbe8fa235a0a3614cb54386/orjson-3.11.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a16bcd08ab0bcdfc7e8801d9c4a9cc17e58418e4d48ddc6ded4e9e4b1a94062b", size = 130812, upload-time = "2026-02-02T15:37:34.204Z" }, + { url = "https://files.pythonhosted.org/packages/28/e6/59f932bcabd1eac44e334fe8e3281a92eacfcb450586e1f4bde0423728d8/orjson-3.11.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c0b51672e466fd7e56230ffbae7f1639e18d0ce023351fb75da21b71bc2c960", size = 133444, upload-time = "2026-02-02T15:37:35.446Z" }, + { url = "https://files.pythonhosted.org/packages/f1/36/b0f05c0eaa7ca30bc965e37e6a2956b0d67adb87a9872942d3568da846ae/orjson-3.11.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:136dcd6a2e796dfd9ffca9fc027d778567b0b7c9968d092842d3c323cef88aa8", size = 138609, upload-time = "2026-02-02T15:37:36.657Z" }, + { url = "https://files.pythonhosted.org/packages/b8/03/58ec7d302b8d86944c60c7b4b82975d5161fcce4c9bc8c6cb1d6741b6115/orjson-3.11.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:7ba61079379b0ae29e117db13bda5f28d939766e410d321ec1624afc6a0b0504", size = 408918, upload-time = "2026-02-02T15:37:38.076Z" }, + { url = "https://files.pythonhosted.org/packages/06/3a/868d65ef9a8b99be723bd510de491349618abd9f62c826cf206d962db295/orjson-3.11.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0527a4510c300e3b406591b0ba69b5dc50031895b0a93743526a3fc45f59d26e", size = 143998, upload-time = "2026-02-02T15:37:39.706Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c7/1e18e1c83afe3349f4f6dc9e14910f0ae5f82eac756d1412ea4018938535/orjson-3.11.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a709e881723c9b18acddcfb8ba357322491ad553e277cf467e1e7e20e2d90561", size = 134802, upload-time = "2026-02-02T15:37:41.002Z" }, + { url = "https://files.pythonhosted.org/packages/d4/0b/ccb7ee1a65b37e8eeb8b267dc953561d72370e85185e459616d4345bab34/orjson-3.11.7-cp311-cp311-win32.whl", hash = "sha256:c43b8b5bab288b6b90dac410cca7e986a4fa747a2e8f94615aea407da706980d", size = 127828, upload-time = "2026-02-02T15:37:42.241Z" }, + { url = "https://files.pythonhosted.org/packages/af/9e/55c776dffda3f381e0f07d010a4f5f3902bf48eaba1bb7684d301acd4924/orjson-3.11.7-cp311-cp311-win_amd64.whl", hash = "sha256:6543001328aa857187f905308a028935864aefe9968af3848401b6fe80dbb471", size = 124941, upload-time = "2026-02-02T15:37:43.444Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8e/424a620fa7d263b880162505fb107ef5e0afaa765b5b06a88312ac291560/orjson-3.11.7-cp311-cp311-win_arm64.whl", hash = "sha256:1ee5cc7160a821dfe14f130bc8e63e7611051f964b463d9e2a3a573204446a4d", size = 126245, upload-time = "2026-02-02T15:37:45.18Z" }, + { url = "https://files.pythonhosted.org/packages/80/bf/76f4f1665f6983385938f0e2a5d7efa12a58171b8456c252f3bae8a4cf75/orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f", size = 228545, upload-time = "2026-02-02T15:37:46.376Z" }, + { url = "https://files.pythonhosted.org/packages/79/53/6c72c002cb13b5a978a068add59b25a8bdf2800ac1c9c8ecdb26d6d97064/orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b", size = 125224, upload-time = "2026-02-02T15:37:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/2c/83/10e48852865e5dd151bdfe652c06f7da484578ed02c5fca938e3632cb0b8/orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a", size = 128154, upload-time = "2026-02-02T15:37:48.954Z" }, + { url = "https://files.pythonhosted.org/packages/6e/52/a66e22a2b9abaa374b4a081d410edab6d1e30024707b87eab7c734afe28d/orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10", size = 123548, upload-time = "2026-02-02T15:37:50.187Z" }, + { url = "https://files.pythonhosted.org/packages/de/38/605d371417021359f4910c496f764c48ceb8997605f8c25bf1dfe58c0ebe/orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa", size = 129000, upload-time = "2026-02-02T15:37:51.426Z" }, + { url = "https://files.pythonhosted.org/packages/44/98/af32e842b0ffd2335c89714d48ca4e3917b42f5d6ee5537832e069a4b3ac/orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8", size = 141686, upload-time = "2026-02-02T15:37:52.607Z" }, + { url = "https://files.pythonhosted.org/packages/96/0b/fc793858dfa54be6feee940c1463370ece34b3c39c1ca0aa3845f5ba9892/orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f", size = 130812, upload-time = "2026-02-02T15:37:53.944Z" }, + { url = "https://files.pythonhosted.org/packages/dc/91/98a52415059db3f374757d0b7f0f16e3b5cd5976c90d1c2b56acaea039e6/orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad", size = 133440, upload-time = "2026-02-02T15:37:55.615Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/cb540117bda61791f46381f8c26c8f93e802892830a6055748d3bb1925ab/orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867", size = 138386, upload-time = "2026-02-02T15:37:56.814Z" }, + { url = "https://files.pythonhosted.org/packages/63/1a/50a3201c334a7f17c231eee5f841342190723794e3b06293f26e7cf87d31/orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d", size = 408853, upload-time = "2026-02-02T15:37:58.291Z" }, + { url = "https://files.pythonhosted.org/packages/87/cd/8de1c67d0be44fdc22701e5989c0d015a2adf391498ad42c4dc589cd3013/orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab", size = 144130, upload-time = "2026-02-02T15:38:00.163Z" }, + { url = "https://files.pythonhosted.org/packages/0f/fe/d605d700c35dd55f51710d159fc54516a280923cd1b7e47508982fbb387d/orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2", size = 134818, upload-time = "2026-02-02T15:38:01.507Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e4/15ecc67edb3ddb3e2f46ae04475f2d294e8b60c1825fbe28a428b93b3fbd/orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f", size = 127923, upload-time = "2026-02-02T15:38:02.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/70/2e0855361f76198a3965273048c8e50a9695d88cd75811a5b46444895845/orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74", size = 125007, upload-time = "2026-02-02T15:38:04.032Z" }, + { url = "https://files.pythonhosted.org/packages/68/40/c2051bd19fc467610fed469dc29e43ac65891571138f476834ca192bc290/orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5", size = 126089, upload-time = "2026-02-02T15:38:05.297Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/6e0e52cac5aab51d7b6dcd257e855e1dec1c2060f6b28566c509b4665f62/orjson-3.11.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1d98b30cc1313d52d4af17d9c3d307b08389752ec5f2e5febdfada70b0f8c733", size = 228390, upload-time = "2026-02-02T15:38:06.8Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/a77f48d2fc8a05bbc529e5ff481fb43d914f9e383ea2469d4f3d51df3d00/orjson-3.11.7-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d897e81f8d0cbd2abb82226d1860ad2e1ab3ff16d7b08c96ca00df9d45409ef4", size = 125189, upload-time = "2026-02-02T15:38:08.181Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/0a16e0729a0e6a1504f9d1a13cdd365f030068aab64cec6958396b9969d7/orjson-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814be4b49b228cfc0b3c565acf642dd7d13538f966e3ccde61f4f55be3e20785", size = 128106, upload-time = "2026-02-02T15:38:09.41Z" }, + { url = "https://files.pythonhosted.org/packages/66/da/a2e505469d60666a05ab373f1a6322eb671cb2ba3a0ccfc7d4bc97196787/orjson-3.11.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d06e5c5fed5caedd2e540d62e5b1c25e8c82431b9e577c33537e5fa4aa909539", size = 123363, upload-time = "2026-02-02T15:38:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/23/bf/ed73f88396ea35c71b38961734ea4a4746f7ca0768bf28fd551d37e48dd0/orjson-3.11.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31c80ce534ac4ea3739c5ee751270646cbc46e45aea7576a38ffec040b4029a1", size = 129007, upload-time = "2026-02-02T15:38:12.138Z" }, + { url = "https://files.pythonhosted.org/packages/73/3c/b05d80716f0225fc9008fbf8ab22841dcc268a626aa550561743714ce3bf/orjson-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f50979824bde13d32b4320eedd513431c921102796d86be3eee0b58e58a3ecd1", size = 141667, upload-time = "2026-02-02T15:38:13.398Z" }, + { url = "https://files.pythonhosted.org/packages/61/e8/0be9b0addd9bf86abfc938e97441dcd0375d494594b1c8ad10fe57479617/orjson-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e54f3808e2b6b945078c41aa8d9b5834b28c50843846e97807e5adb75fa9705", size = 130832, upload-time = "2026-02-02T15:38:14.698Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ec/c68e3b9021a31d9ec15a94931db1410136af862955854ed5dd7e7e4f5bff/orjson-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12b80df61aab7b98b490fe9e4879925ba666fccdfcd175252ce4d9035865ace", size = 133373, upload-time = "2026-02-02T15:38:16.109Z" }, + { url = "https://files.pythonhosted.org/packages/d2/45/f3466739aaafa570cc8e77c6dbb853c48bf56e3b43738020e2661e08b0ac/orjson-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:996b65230271f1a97026fd0e6a753f51fbc0c335d2ad0c6201f711b0da32693b", size = 138307, upload-time = "2026-02-02T15:38:17.453Z" }, + { url = "https://files.pythonhosted.org/packages/e1/84/9f7f02288da1ffb31405c1be07657afd1eecbcb4b64ee2817b6fe0f785fa/orjson-3.11.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ab49d4b2a6a1d415ddb9f37a21e02e0d5dbfe10b7870b21bf779fc21e9156157", size = 408695, upload-time = "2026-02-02T15:38:18.831Z" }, + { url = "https://files.pythonhosted.org/packages/18/07/9dd2f0c0104f1a0295ffbe912bc8d63307a539b900dd9e2c48ef7810d971/orjson-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:390a1dce0c055ddf8adb6aa94a73b45a4a7d7177b5c584b8d1c1947f2ba60fb3", size = 144099, upload-time = "2026-02-02T15:38:20.28Z" }, + { url = "https://files.pythonhosted.org/packages/a5/66/857a8e4a3292e1f7b1b202883bcdeb43a91566cf59a93f97c53b44bd6801/orjson-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1eb80451a9c351a71dfaf5b7ccc13ad065405217726b59fdbeadbcc544f9d223", size = 134806, upload-time = "2026-02-02T15:38:22.186Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5b/6ebcf3defc1aab3a338ca777214966851e92efb1f30dc7fc8285216e6d1b/orjson-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7477aa6a6ec6139c5cb1cc7b214643592169a5494d200397c7fc95d740d5fcf3", size = 127914, upload-time = "2026-02-02T15:38:23.511Z" }, + { url = "https://files.pythonhosted.org/packages/00/04/c6f72daca5092e3117840a1b1e88dfc809cc1470cf0734890d0366b684a1/orjson-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:b9f95dcdea9d4f805daa9ddf02617a89e484c6985fa03055459f90e87d7a0757", size = 124986, upload-time = "2026-02-02T15:38:24.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/ba/077a0f6f1085d6b806937246860fafbd5b17f3919c70ee3f3d8d9c713f38/orjson-3.11.7-cp313-cp313-win_arm64.whl", hash = "sha256:800988273a014a0541483dc81021247d7eacb0c845a9d1a34a422bc718f41539", size = 126045, upload-time = "2026-02-02T15:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1e/745565dca749813db9a093c5ebc4bac1a9475c64d54b95654336ac3ed961/orjson-3.11.7-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:de0a37f21d0d364954ad5de1970491d7fbd0fb1ef7417d4d56a36dc01ba0c0a0", size = 228391, upload-time = "2026-02-02T15:38:27.757Z" }, + { url = "https://files.pythonhosted.org/packages/46/19/e40f6225da4d3aa0c8dc6e5219c5e87c2063a560fe0d72a88deb59776794/orjson-3.11.7-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c2428d358d85e8da9d37cba18b8c4047c55222007a84f97156a5b22028dfbfc0", size = 125188, upload-time = "2026-02-02T15:38:29.241Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7e/c4de2babef2c0817fd1f048fd176aa48c37bec8aef53d2fa932983032cce/orjson-3.11.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4bc6c6ac52cdaa267552544c73e486fecbd710b7ac09bc024d5a78555a22f6", size = 128097, upload-time = "2026-02-02T15:38:30.618Z" }, + { url = "https://files.pythonhosted.org/packages/eb/74/233d360632bafd2197f217eee7fb9c9d0229eac0c18128aee5b35b0014fe/orjson-3.11.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd0d68edd7dfca1b2eca9361a44ac9f24b078de3481003159929a0573f21a6bf", size = 123364, upload-time = "2026-02-02T15:38:32.363Z" }, + { url = "https://files.pythonhosted.org/packages/79/51/af79504981dd31efe20a9e360eb49c15f06df2b40e7f25a0a52d9ae888e8/orjson-3.11.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:623ad1b9548ef63886319c16fa317848e465a21513b31a6ad7b57443c3e0dcf5", size = 129076, upload-time = "2026-02-02T15:38:33.68Z" }, + { url = "https://files.pythonhosted.org/packages/67/e2/da898eb68b72304f8de05ca6715870d09d603ee98d30a27e8a9629abc64b/orjson-3.11.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e776b998ac37c0396093d10290e60283f59cfe0fc3fccbd0ccc4bd04dd19892", size = 141705, upload-time = "2026-02-02T15:38:34.989Z" }, + { url = "https://files.pythonhosted.org/packages/c5/89/15364d92acb3d903b029e28d834edb8780c2b97404cbf7929aa6b9abdb24/orjson-3.11.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c6c3af76716f4a9c290371ba2e390ede06f6603edb277b481daf37f6f464e", size = 130855, upload-time = "2026-02-02T15:38:36.379Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8b/ecdad52d0b38d4b8f514be603e69ccd5eacf4e7241f972e37e79792212ec/orjson-3.11.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a56df3239294ea5964adf074c54bcc4f0ccd21636049a2cf3ca9cf03b5d03cf1", size = 133386, upload-time = "2026-02-02T15:38:37.704Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0e/45e1dcf10e17d0924b7c9162f87ec7b4ca79e28a0548acf6a71788d3e108/orjson-3.11.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda117c4148e81f746655d5a3239ae9bd00cb7bc3ca178b5fc5a5997e9744183", size = 138295, upload-time = "2026-02-02T15:38:39.096Z" }, + { url = "https://files.pythonhosted.org/packages/63/d7/4d2e8b03561257af0450f2845b91fbd111d7e526ccdf737267108075e0ba/orjson-3.11.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:23d6c20517a97a9daf1d48b580fcdc6f0516c6f4b5038823426033690b4d2650", size = 408720, upload-time = "2026-02-02T15:38:40.634Z" }, + { url = "https://files.pythonhosted.org/packages/78/cf/d45343518282108b29c12a65892445fc51f9319dc3c552ceb51bb5905ed2/orjson-3.11.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8ff206156006da5b847c9304b6308a01e8cdbc8cce824e2779a5ba71c3def141", size = 144152, upload-time = "2026-02-02T15:38:42.262Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3a/d6001f51a7275aacd342e77b735c71fa04125a3f93c36fee4526bc8c654e/orjson-3.11.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:962d046ee1765f74a1da723f4b33e3b228fe3a48bd307acce5021dfefe0e29b2", size = 134814, upload-time = "2026-02-02T15:38:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d3/f19b47ce16820cc2c480f7f1723e17f6d411b3a295c60c8ad3aa9ff1c96a/orjson-3.11.7-cp314-cp314-win32.whl", hash = "sha256:89e13dd3f89f1c38a9c9eba5fbf7cdc2d1feca82f5f290864b4b7a6aac704576", size = 127997, upload-time = "2026-02-02T15:38:45.06Z" }, + { url = "https://files.pythonhosted.org/packages/12/df/172771902943af54bf661a8d102bdf2e7f932127968080632bda6054b62c/orjson-3.11.7-cp314-cp314-win_amd64.whl", hash = "sha256:845c3e0d8ded9c9271cd79596b9b552448b885b97110f628fb687aee2eed11c1", size = 124985, upload-time = "2026-02-02T15:38:46.388Z" }, + { url = "https://files.pythonhosted.org/packages/6f/1c/f2a8d8a1b17514660a614ce5f7aac74b934e69f5abc2700cc7ced882a009/orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d", size = 126038, upload-time = "2026-02-02T15:38:47.703Z" }, +] + +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950, upload-time = "2024-11-08T09:47:47.202Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451, upload-time = "2024-11-08T09:47:44.722Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/0c/b28ed414f080ee0ad153f848586d61d1878f91689950f037f976ce15f6c8/pandas-3.0.1.tar.gz", hash = "sha256:4186a699674af418f655dbd420ed87f50d56b4cd6603784279d9eef6627823c8", size = 4641901, upload-time = "2026-02-17T22:20:16.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/07/c7087e003ceee9b9a82539b40414ec557aa795b584a1a346e89180853d79/pandas-3.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:de09668c1bf3b925c07e5762291602f0d789eca1b3a781f99c1c78f6cac0e7ea", size = 10323380, upload-time = "2026-02-17T22:18:16.133Z" }, + { url = "https://files.pythonhosted.org/packages/c1/27/90683c7122febeefe84a56f2cde86a9f05f68d53885cebcc473298dfc33e/pandas-3.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:24ba315ba3d6e5806063ac6eb717504e499ce30bd8c236d8693a5fd3f084c796", size = 9923455, upload-time = "2026-02-17T22:18:19.13Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f1/ed17d927f9950643bc7631aa4c99ff0cc83a37864470bc419345b656a41f/pandas-3.0.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:406ce835c55bac912f2a0dcfaf27c06d73c6b04a5dde45f1fd3169ce31337389", size = 10753464, upload-time = "2026-02-17T22:18:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/2e/7c/870c7e7daec2a6c7ff2ac9e33b23317230d4e4e954b35112759ea4a924a7/pandas-3.0.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:830994d7e1f31dd7e790045235605ab61cff6c94defc774547e8b7fdfbff3dc7", size = 11255234, upload-time = "2026-02-17T22:18:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/5c/39/3653fe59af68606282b989c23d1a543ceba6e8099cbcc5f1d506a7bae2aa/pandas-3.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a64ce8b0f2de1d2efd2ae40b0abe7f8ae6b29fbfb3812098ed5a6f8e235ad9bf", size = 11767299, upload-time = "2026-02-17T22:18:26.824Z" }, + { url = "https://files.pythonhosted.org/packages/9b/31/1daf3c0c94a849c7a8dab8a69697b36d313b229918002ba3e409265c7888/pandas-3.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9832c2c69da24b602c32e0c7b1b508a03949c18ba08d4d9f1c1033426685b447", size = 12333292, upload-time = "2026-02-17T22:18:28.996Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/af63f83cd6ca603a00fe8530c10a60f0879265b8be00b5930e8e78c5b30b/pandas-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:84f0904a69e7365f79a0c77d3cdfccbfb05bf87847e3a51a41e1426b0edb9c79", size = 9892176, upload-time = "2026-02-17T22:18:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/79/ab/9c776b14ac4b7b4140788eca18468ea39894bc7340a408f1d1e379856a6b/pandas-3.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:4a68773d5a778afb31d12e34f7dd4612ab90de8c6fb1d8ffe5d4a03b955082a1", size = 9151328, upload-time = "2026-02-17T22:18:35.721Z" }, + { url = "https://files.pythonhosted.org/packages/37/51/b467209c08dae2c624873d7491ea47d2b47336e5403309d433ea79c38571/pandas-3.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:476f84f8c20c9f5bc47252b66b4bb25e1a9fc2fa98cead96744d8116cb85771d", size = 10344357, upload-time = "2026-02-17T22:18:38.262Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f1/e2567ffc8951ab371db2e40b2fe068e36b81d8cf3260f06ae508700e5504/pandas-3.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0ab749dfba921edf641d4036c4c21c0b3ea70fea478165cb98a998fb2a261955", size = 9884543, upload-time = "2026-02-17T22:18:41.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/327802e0b6d693182403c144edacbc27eb82907b57062f23ef5a4c4a5ea7/pandas-3.0.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8e36891080b87823aff3640c78649b91b8ff6eea3c0d70aeabd72ea43ab069b", size = 10396030, upload-time = "2026-02-17T22:18:43.822Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fe/89d77e424365280b79d99b3e1e7d606f5165af2f2ecfaf0c6d24c799d607/pandas-3.0.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:532527a701281b9dd371e2f582ed9094f4c12dd9ffb82c0c54ee28d8ac9520c4", size = 10876435, upload-time = "2026-02-17T22:18:45.954Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a6/2a75320849dd154a793f69c951db759aedb8d1dd3939eeacda9bdcfa1629/pandas-3.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:356e5c055ed9b0da1580d465657bc7d00635af4fd47f30afb23025352ba764d1", size = 11405133, upload-time = "2026-02-17T22:18:48.533Z" }, + { url = "https://files.pythonhosted.org/packages/58/53/1d68fafb2e02d7881df66aa53be4cd748d25cbe311f3b3c85c93ea5d30ca/pandas-3.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9d810036895f9ad6345b8f2a338dd6998a74e8483847403582cab67745bff821", size = 11932065, upload-time = "2026-02-17T22:18:50.837Z" }, + { url = "https://files.pythonhosted.org/packages/75/08/67cc404b3a966b6df27b38370ddd96b3b023030b572283d035181854aac5/pandas-3.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:536232a5fe26dd989bd633e7a0c450705fdc86a207fec7254a55e9a22950fe43", size = 9741627, upload-time = "2026-02-17T22:18:53.905Z" }, + { url = "https://files.pythonhosted.org/packages/86/4f/caf9952948fb00d23795f09b893d11f1cacb384e666854d87249530f7cbe/pandas-3.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f463ebfd8de7f326d38037c7363c6dacb857c5881ab8961fb387804d6daf2f7", size = 9052483, upload-time = "2026-02-17T22:18:57.31Z" }, + { url = "https://files.pythonhosted.org/packages/0b/48/aad6ec4f8d007534c091e9a7172b3ec1b1ee6d99a9cbb936b5eab6c6cf58/pandas-3.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5272627187b5d9c20e55d27caf5f2cd23e286aba25cadf73c8590e432e2b7262", size = 10317509, upload-time = "2026-02-17T22:18:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/a8/14/5990826f779f79148ae9d3a2c39593dc04d61d5d90541e71b5749f35af95/pandas-3.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:661e0f665932af88c7877f31da0dc743fe9c8f2524bdffe23d24fdcb67ef9d56", size = 9860561, upload-time = "2026-02-17T22:19:02.265Z" }, + { url = "https://files.pythonhosted.org/packages/fa/80/f01ff54664b6d70fed71475543d108a9b7c888e923ad210795bef04ffb7d/pandas-3.0.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75e6e292ff898679e47a2199172593d9f6107fd2dd3617c22c2946e97d5df46e", size = 10365506, upload-time = "2026-02-17T22:19:05.017Z" }, + { url = "https://files.pythonhosted.org/packages/f2/85/ab6d04733a7d6ff32bfc8382bf1b07078228f5d6ebec5266b91bfc5c4ff7/pandas-3.0.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ff8cf1d2896e34343197685f432450ec99a85ba8d90cce2030c5eee2ef98791", size = 10873196, upload-time = "2026-02-17T22:19:07.204Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/9301c83d0b47c23ac5deab91c6b39fd98d5b5db4d93b25df8d381451828f/pandas-3.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eca8b4510f6763f3d37359c2105df03a7a221a508f30e396a51d0713d462e68a", size = 11370859, upload-time = "2026-02-17T22:19:09.436Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/0c1fc5bd2d29c7db2ab372330063ad555fb83e08422829c785f5ec2176ca/pandas-3.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06aff2ad6f0b94a17822cf8b83bbb563b090ed82ff4fe7712db2ce57cd50d9b8", size = 11924584, upload-time = "2026-02-17T22:19:11.562Z" }, + { url = "https://files.pythonhosted.org/packages/d6/7d/216a1588b65a7aa5f4535570418a599d943c85afb1d95b0876fc00aa1468/pandas-3.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9fea306c783e28884c29057a1d9baa11a349bbf99538ec1da44c8476563d1b25", size = 9742769, upload-time = "2026-02-17T22:19:13.926Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cb/810a22a6af9a4e97c8ab1c946b47f3489c5bca5adc483ce0ffc84c9cc768/pandas-3.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:a8d37a43c52917427e897cb2e429f67a449327394396a81034a4449b99afda59", size = 9043855, upload-time = "2026-02-17T22:19:16.09Z" }, + { url = "https://files.pythonhosted.org/packages/92/fa/423c89086cca1f039cf1253c3ff5b90f157b5b3757314aa635f6bf3e30aa/pandas-3.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d54855f04f8246ed7b6fc96b05d4871591143c46c0b6f4af874764ed0d2d6f06", size = 10752673, upload-time = "2026-02-17T22:19:18.304Z" }, + { url = "https://files.pythonhosted.org/packages/22/23/b5a08ec1f40020397f0faba72f1e2c11f7596a6169c7b3e800abff0e433f/pandas-3.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e1b677accee34a09e0dc2ce5624e4a58a1870ffe56fc021e9caf7f23cd7668f", size = 10404967, upload-time = "2026-02-17T22:19:20.726Z" }, + { url = "https://files.pythonhosted.org/packages/5c/81/94841f1bb4afdc2b52a99daa895ac2c61600bb72e26525ecc9543d453ebc/pandas-3.0.1-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9cabbdcd03f1b6cd254d6dda8ae09b0252524be1592594c00b7895916cb1324", size = 10320575, upload-time = "2026-02-17T22:19:24.919Z" }, + { url = "https://files.pythonhosted.org/packages/0a/8b/2ae37d66a5342a83adadfd0cb0b4bf9c3c7925424dd5f40d15d6cfaa35ee/pandas-3.0.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ae2ab1f166668b41e770650101e7090824fd34d17915dd9cd479f5c5e0065e9", size = 10710921, upload-time = "2026-02-17T22:19:27.181Z" }, + { url = "https://files.pythonhosted.org/packages/a2/61/772b2e2757855e232b7ccf7cb8079a5711becb3a97f291c953def15a833f/pandas-3.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6bf0603c2e30e2cafac32807b06435f28741135cb8697eae8b28c7d492fc7d76", size = 11334191, upload-time = "2026-02-17T22:19:29.411Z" }, + { url = "https://files.pythonhosted.org/packages/1b/08/b16c6df3ef555d8495d1d265a7963b65be166785d28f06a350913a4fac78/pandas-3.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6c426422973973cae1f4a23e51d4ae85974f44871b24844e4f7de752dd877098", size = 11782256, upload-time = "2026-02-17T22:19:32.34Z" }, + { url = "https://files.pythonhosted.org/packages/55/80/178af0594890dee17e239fca96d3d8670ba0f5ff59b7d0439850924a9c09/pandas-3.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b03f91ae8c10a85c1613102c7bef5229b5379f343030a3ccefeca8a33414cf35", size = 10485047, upload-time = "2026-02-17T22:19:34.605Z" }, + { url = "https://files.pythonhosted.org/packages/bb/8b/4bb774a998b97e6c2fd62a9e6cfdaae133b636fd1c468f92afb4ae9a447a/pandas-3.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:99d0f92ed92d3083d140bf6b97774f9f13863924cf3f52a70711f4e7588f9d0a", size = 10322465, upload-time = "2026-02-17T22:19:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/72/3a/5b39b51c64159f470f1ca3b1c2a87da290657ca022f7cd11442606f607d1/pandas-3.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3b66857e983208654294bb6477b8a63dee26b37bdd0eb34d010556e91261784f", size = 9910632, upload-time = "2026-02-17T22:19:39.001Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f7/b449ffb3f68c11da12fc06fbf6d2fa3a41c41e17d0284d23a79e1c13a7e4/pandas-3.0.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56cf59638bf24dc9bdf2154c81e248b3289f9a09a6d04e63608c159022352749", size = 10440535, upload-time = "2026-02-17T22:19:41.157Z" }, + { url = "https://files.pythonhosted.org/packages/55/77/6ea82043db22cb0f2bbfe7198da3544000ddaadb12d26be36e19b03a2dc5/pandas-3.0.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1a9f55e0f46951874b863d1f3906dcb57df2d9be5c5847ba4dfb55b2c815249", size = 10893940, upload-time = "2026-02-17T22:19:43.493Z" }, + { url = "https://files.pythonhosted.org/packages/03/30/f1b502a72468c89412c1b882a08f6eed8a4ee9dc033f35f65d0663df6081/pandas-3.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1849f0bba9c8a2fb0f691d492b834cc8dadf617e29015c66e989448d58d011ee", size = 11442711, upload-time = "2026-02-17T22:19:46.074Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f0/ebb6ddd8fc049e98cabac5c2924d14d1dda26a20adb70d41ea2e428d3ec4/pandas-3.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3d288439e11b5325b02ae6e9cc83e6805a62c40c5a6220bea9beb899c073b1c", size = 11963918, upload-time = "2026-02-17T22:19:48.838Z" }, + { url = "https://files.pythonhosted.org/packages/09/f8/8ce132104074f977f907442790eaae24e27bce3b3b454e82faa3237ff098/pandas-3.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:93325b0fe372d192965f4cca88d97667f49557398bbf94abdda3bf1b591dbe66", size = 9862099, upload-time = "2026-02-17T22:19:51.081Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b7/6af9aac41ef2456b768ef0ae60acf8abcebb450a52043d030a65b4b7c9bd/pandas-3.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:97ca08674e3287c7148f4858b01136f8bdfe7202ad25ad04fec602dd1d29d132", size = 9185333, upload-time = "2026-02-17T22:19:53.266Z" }, + { url = "https://files.pythonhosted.org/packages/66/fc/848bb6710bc6061cb0c5badd65b92ff75c81302e0e31e496d00029fe4953/pandas-3.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:58eeb1b2e0fb322befcf2bbc9ba0af41e616abadb3d3414a6bc7167f6cbfce32", size = 10772664, upload-time = "2026-02-17T22:19:55.806Z" }, + { url = "https://files.pythonhosted.org/packages/69/5c/866a9bbd0f79263b4b0db6ec1a341be13a1473323f05c122388e0f15b21d/pandas-3.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cd9af1276b5ca9e298bd79a26bda32fa9cc87ed095b2a9a60978d2ca058eaf87", size = 10421286, upload-time = "2026-02-17T22:19:58.091Z" }, + { url = "https://files.pythonhosted.org/packages/51/a4/2058fb84fb1cfbfb2d4a6d485e1940bb4ad5716e539d779852494479c580/pandas-3.0.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f87a04984d6b63788327cd9f79dda62b7f9043909d2440ceccf709249ca988", size = 10342050, upload-time = "2026-02-17T22:20:01.376Z" }, + { url = "https://files.pythonhosted.org/packages/22/1b/674e89996cc4be74db3c4eb09240c4bb549865c9c3f5d9b086ff8fcfbf00/pandas-3.0.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85fe4c4df62e1e20f9db6ebfb88c844b092c22cd5324bdcf94bfa2fc1b391221", size = 10740055, upload-time = "2026-02-17T22:20:04.328Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f8/e954b750764298c22fa4614376531fe63c521ef517e7059a51f062b87dca/pandas-3.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:331ca75a2f8672c365ae25c0b29e46f5ac0c6551fdace8eec4cd65e4fac271ff", size = 11357632, upload-time = "2026-02-17T22:20:06.647Z" }, + { url = "https://files.pythonhosted.org/packages/6d/02/c6e04b694ffd68568297abd03588b6d30295265176a5c01b7459d3bc35a3/pandas-3.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15860b1fdb1973fffade772fdb931ccf9b2f400a3f5665aef94a00445d7d8dd5", size = 11810974, upload-time = "2026-02-17T22:20:08.946Z" }, + { url = "https://files.pythonhosted.org/packages/89/41/d7dfb63d2407f12055215070c42fc6ac41b66e90a2946cdc5e759058398b/pandas-3.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:44f1364411d5670efa692b146c748f4ed013df91ee91e9bec5677fb1fd58b937", size = 10884622, upload-time = "2026-02-17T22:20:11.711Z" }, + { url = "https://files.pythonhosted.org/packages/68/b0/34937815889fa982613775e4b97fddd13250f11012d769949c5465af2150/pandas-3.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:108dd1790337a494aa80e38def654ca3f0968cf4f362c85f44c15e471667102d", size = 9452085, upload-time = "2026-02-17T22:20:14.331Z" }, +] + +[[package]] +name = "patsy" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/44/ed13eccdd0519eff265f44b670d46fbb0ec813e2274932dc1c0e48520f7d/patsy-1.0.2.tar.gz", hash = "sha256:cdc995455f6233e90e22de72c37fcadb344e7586fb83f06696f54d92f8ce74c0", size = 399942, upload-time = "2025-10-20T16:17:37.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/70/ba4b949bdc0490ab78d545459acd7702b211dfccf7eb89bbc1060f52818d/patsy-1.0.2-py2.py3-none-any.whl", hash = "sha256:37bfddbc58fcf0362febb5f54f10743f8b21dd2aa73dec7e7ef59d1b02ae668a", size = 233301, upload-time = "2025-10-20T16:17:36.563Z" }, +] + +[[package]] +name = "pillow" +version = "12.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798, upload-time = "2025-10-15T18:21:47.763Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589, upload-time = "2025-10-15T18:21:49.515Z" }, + { url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472, upload-time = "2025-10-15T18:21:51.052Z" }, + { url = "https://files.pythonhosted.org/packages/4f/89/63427f51c64209c5e23d4d52071c8d0f21024d3a8a487737caaf614a5795/pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", size = 8033887, upload-time = "2025-10-15T18:21:52.604Z" }, + { url = "https://files.pythonhosted.org/packages/f6/1b/c9711318d4901093c15840f268ad649459cd81984c9ec9887756cca049a5/pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", size = 6343964, upload-time = "2025-10-15T18:21:54.619Z" }, + { url = "https://files.pythonhosted.org/packages/41/1e/db9470f2d030b4995083044cd8738cdd1bf773106819f6d8ba12597d5352/pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", size = 7034756, upload-time = "2025-10-15T18:21:56.151Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b0/6177a8bdd5ee4ed87cba2de5a3cc1db55ffbbec6176784ce5bb75aa96798/pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", size = 6458075, upload-time = "2025-10-15T18:21:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/bc/5e/61537aa6fa977922c6a03253a0e727e6e4a72381a80d63ad8eec350684f2/pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", size = 7125955, upload-time = "2025-10-15T18:21:59.372Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/d5033539344ee3cbd9a4d69e12e63ca3a44a739eb2d4c8da350a3d38edd7/pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", size = 6298440, upload-time = "2025-10-15T18:22:00.982Z" }, + { url = "https://files.pythonhosted.org/packages/4d/42/aaca386de5cc8bd8a0254516957c1f265e3521c91515b16e286c662854c4/pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", size = 6999256, upload-time = "2025-10-15T18:22:02.617Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f1/9197c9c2d5708b785f631a6dfbfa8eb3fb9672837cb92ae9af812c13b4ed/pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", size = 2436025, upload-time = "2025-10-15T18:22:04.598Z" }, + { url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" }, + { url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" }, + { url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" }, + { url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" }, + { url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" }, + { url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" }, + { url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" }, + { url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" }, + { url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" }, + { url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" }, + { url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" }, + { url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" }, + { url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" }, + { url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" }, + { url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" }, + { url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" }, + { url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" }, + { url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" }, + { url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" }, + { url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" }, + { url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" }, + { url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" }, + { url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" }, + { url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" }, + { url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" }, + { url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" }, + { url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" }, + { url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" }, + { url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" }, + { url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" }, + { url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" }, + { url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" }, + { url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" }, + { url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" }, + { url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068, upload-time = "2025-10-15T18:23:59.594Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994, upload-time = "2025-10-15T18:24:01.669Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e1/f8281e5d844c41872b273b9f2c34a4bf64ca08905668c8ae730eedc7c9fa/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", size = 5246639, upload-time = "2025-10-15T18:24:03.403Z" }, + { url = "https://files.pythonhosted.org/packages/94/5a/0d8ab8ffe8a102ff5df60d0de5af309015163bf710c7bb3e8311dd3b3ad0/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", size = 6986839, upload-time = "2025-10-15T18:24:05.344Z" }, + { url = "https://files.pythonhosted.org/packages/20/2e/3434380e8110b76cd9eb00a363c484b050f949b4bbe84ba770bb8508a02c/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", size = 5313505, upload-time = "2025-10-15T18:24:07.137Z" }, + { url = "https://files.pythonhosted.org/packages/57/ca/5a9d38900d9d74785141d6580950fe705de68af735ff6e727cb911b64740/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", size = 5963654, upload-time = "2025-10-15T18:24:09.579Z" }, + { url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/10/97ee2fa54dff1e9da9badbc5e35d0bbaef0776271ea5907eccf64140f72f/pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af", size = 177815, upload-time = "2024-07-28T19:59:01.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/92/caae8c86e94681b42c246f0bca35c059a2f0529e5b92619f6aba4cf7e7b6/pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f", size = 204643, upload-time = "2024-07-28T19:58:59.335Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" }, + { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" }, + { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929, upload-time = "2025-10-08T19:46:28.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778, upload-time = "2025-10-08T19:46:30.358Z" }, + { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144, upload-time = "2025-10-08T19:46:32.607Z" }, + { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030, upload-time = "2025-10-08T19:46:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252, upload-time = "2025-10-08T19:46:35.309Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064, upload-time = "2025-10-08T19:46:36.993Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429, upload-time = "2025-10-08T19:46:38.398Z" }, + { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727, upload-time = "2025-10-08T19:46:39.732Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097, upload-time = "2025-10-08T19:46:41.025Z" }, + { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084, upload-time = "2025-10-08T19:46:42.693Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637, upload-time = "2025-10-08T19:46:43.778Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064, upload-time = "2025-10-08T19:46:44.872Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/03/a1440979a3f74f16cab3b75b0da1a1a7f922d56a8ddea96092391998edc0/protobuf-6.33.1.tar.gz", hash = "sha256:97f65757e8d09870de6fd973aeddb92f85435607235d20b2dfed93405d00c85b", size = 443432, upload-time = "2025-11-13T16:44:18.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/f1/446a9bbd2c60772ca36556bac8bfde40eceb28d9cc7838755bc41e001d8f/protobuf-6.33.1-cp310-abi3-win32.whl", hash = "sha256:f8d3fdbc966aaab1d05046d0240dd94d40f2a8c62856d41eaa141ff64a79de6b", size = 425593, upload-time = "2025-11-13T16:44:06.275Z" }, + { url = "https://files.pythonhosted.org/packages/a6/79/8780a378c650e3df849b73de8b13cf5412f521ca2ff9b78a45c247029440/protobuf-6.33.1-cp310-abi3-win_amd64.whl", hash = "sha256:923aa6d27a92bf44394f6abf7ea0500f38769d4b07f4be41cb52bd8b1123b9ed", size = 436883, upload-time = "2025-11-13T16:44:09.222Z" }, + { url = "https://files.pythonhosted.org/packages/cd/93/26213ff72b103ae55bb0d73e7fb91ea570ef407c3ab4fd2f1f27cac16044/protobuf-6.33.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:fe34575f2bdde76ac429ec7b570235bf0c788883e70aee90068e9981806f2490", size = 427522, upload-time = "2025-11-13T16:44:10.475Z" }, + { url = "https://files.pythonhosted.org/packages/c2/32/df4a35247923393aa6b887c3b3244a8c941c32a25681775f96e2b418f90e/protobuf-6.33.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:f8adba2e44cde2d7618996b3fc02341f03f5bc3f2748be72dc7b063319276178", size = 324445, upload-time = "2025-11-13T16:44:11.869Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d0/d796e419e2ec93d2f3fa44888861c3f88f722cde02b7c3488fcc6a166820/protobuf-6.33.1-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:0f4cf01222c0d959c2b399142deb526de420be8236f22c71356e2a544e153c53", size = 339161, upload-time = "2025-11-13T16:44:12.778Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2a/3c5f05a4af06649547027d288747f68525755de692a26a7720dced3652c0/protobuf-6.33.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:8fd7d5e0eb08cd5b87fd3df49bc193f5cfd778701f47e11d127d0afc6c39f1d1", size = 323171, upload-time = "2025-11-13T16:44:14.035Z" }, + { url = "https://files.pythonhosted.org/packages/08/b4/46310463b4f6ceef310f8348786f3cff181cea671578e3d9743ba61a459e/protobuf-6.33.1-py3-none-any.whl", hash = "sha256:d595a9fd694fdeb061a62fbe10eb039cc1e444df81ec9bb70c7fc59ebcb1eafa", size = 170477, upload-time = "2025-11-13T16:44:17.633Z" }, +] + +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.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/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125, upload-time = "2025-11-12T13:05:09.333Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "python-engineio" +version = "4.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "simple-websocket" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/12/bdef9dbeedbe2cdeba2a2056ad27b1fb081557d34b69a97f574843462cae/python_engineio-4.13.1.tar.gz", hash = "sha256:0a853fcef52f5b345425d8c2b921ac85023a04dfcf75d7b74696c61e940fd066", size = 92348, upload-time = "2026-02-06T23:38:06.12Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl", hash = "sha256:f32ad10589859c11053ad7d9bb3c9695cdf862113bfb0d20bc4d890198287399", size = 59847, upload-time = "2026-02-06T23:38:04.861Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, +] + +[[package]] +name = "python-socketio" +version = "5.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bidict" }, + { name = "python-engineio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/81/cf8284f45e32efa18d3848ed82cdd4dcc1b657b082458fbe01ad3e1f2f8d/python_socketio-5.16.1.tar.gz", hash = "sha256:f863f98eacce81ceea2e742f6388e10ca3cdd0764be21d30d5196470edf5ea89", size = 128508, upload-time = "2026-02-06T23:42:07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-py3-none-any.whl", hash = "sha256:a3eb1702e92aa2f2b5d3ba00261b61f062cce51f1cfb6900bf3ab4d1934d2d35", size = 82054, upload-time = "2026-02-06T23:42:05.772Z" }, +] + +[package.optional-dependencies] +asyncio-client = [ + { name = "aiohttp" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { 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 = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "referencing" +version = "0.36.2" +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/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, +] + +[[package]] +name = "regex" +version = "2025.11.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/a9/546676f25e573a4cf00fe8e119b78a37b6a8fe2dc95cda877b30889c9c45/regex-2025.11.3.tar.gz", hash = "sha256:1fedc720f9bb2494ce31a58a1631f9c82df6a09b49c19517ea5cc280b4541e01", size = 414669, upload-time = "2025-11-03T21:34:22.089Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/90/4fb5056e5f03a7048abd2b11f598d464f0c167de4f2a51aa868c376b8c70/regex-2025.11.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eadade04221641516fa25139273505a1c19f9bf97589a05bc4cfcd8b4a618031", size = 488081, upload-time = "2025-11-03T21:31:11.946Z" }, + { url = "https://files.pythonhosted.org/packages/85/23/63e481293fac8b069d84fba0299b6666df720d875110efd0338406b5d360/regex-2025.11.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:feff9e54ec0dd3833d659257f5c3f5322a12eee58ffa360984b716f8b92983f4", size = 290554, upload-time = "2025-11-03T21:31:13.387Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9d/b101d0262ea293a0066b4522dfb722eb6a8785a8c3e084396a5f2c431a46/regex-2025.11.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3b30bc921d50365775c09a7ed446359e5c0179e9e2512beec4a60cbcef6ddd50", size = 288407, upload-time = "2025-11-03T21:31:14.809Z" }, + { url = "https://files.pythonhosted.org/packages/0c/64/79241c8209d5b7e00577ec9dca35cd493cc6be35b7d147eda367d6179f6d/regex-2025.11.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f99be08cfead2020c7ca6e396c13543baea32343b7a9a5780c462e323bd8872f", size = 793418, upload-time = "2025-11-03T21:31:16.556Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e2/23cd5d3573901ce8f9757c92ca4db4d09600b865919b6d3e7f69f03b1afd/regex-2025.11.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6dd329a1b61c0ee95ba95385fb0c07ea0d3fe1a21e1349fa2bec272636217118", size = 860448, upload-time = "2025-11-03T21:31:18.12Z" }, + { url = "https://files.pythonhosted.org/packages/2a/4c/aecf31beeaa416d0ae4ecb852148d38db35391aac19c687b5d56aedf3a8b/regex-2025.11.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c5238d32f3c5269d9e87be0cf096437b7622b6920f5eac4fd202468aaeb34d2", size = 907139, upload-time = "2025-11-03T21:31:20.753Z" }, + { url = "https://files.pythonhosted.org/packages/61/22/b8cb00df7d2b5e0875f60628594d44dba283e951b1ae17c12f99e332cc0a/regex-2025.11.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10483eefbfb0adb18ee9474498c9a32fcf4e594fbca0543bb94c48bac6183e2e", size = 800439, upload-time = "2025-11-03T21:31:22.069Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/c4b20330a5cdc7a8eb265f9ce593f389a6a88a0c5f280cf4d978f33966bc/regex-2025.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:78c2d02bb6e1da0720eedc0bad578049cad3f71050ef8cd065ecc87691bed2b0", size = 782965, upload-time = "2025-11-03T21:31:23.598Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4c/ae3e52988ae74af4b04d2af32fee4e8077f26e51b62ec2d12d246876bea2/regex-2025.11.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e6b49cd2aad93a1790ce9cffb18964f6d3a4b0b3dbdbd5de094b65296fce6e58", size = 854398, upload-time = "2025-11-03T21:31:25.008Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/a8b9cf45874eda14b2e275157ce3b304c87e10fb38d9fc26a6e14eb18227/regex-2025.11.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:885b26aa3ee56433b630502dc3d36ba78d186a00cc535d3806e6bfd9ed3c70ab", size = 845897, upload-time = "2025-11-03T21:31:26.427Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fe/1830eb0236be93d9b145e0bd8ab499f31602fe0999b1f19e99955aa8fe20/regex-2025.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ddd76a9f58e6a00f8772e72cff8ebcff78e022be95edf018766707c730593e1e", size = 788906, upload-time = "2025-11-03T21:31:28.078Z" }, + { url = "https://files.pythonhosted.org/packages/66/47/dc2577c1f95f188c1e13e2e69d8825a5ac582ac709942f8a03af42ed6e93/regex-2025.11.3-cp311-cp311-win32.whl", hash = "sha256:3e816cc9aac1cd3cc9a4ec4d860f06d40f994b5c7b4d03b93345f44e08cc68bf", size = 265812, upload-time = "2025-11-03T21:31:29.72Z" }, + { url = "https://files.pythonhosted.org/packages/50/1e/15f08b2f82a9bbb510621ec9042547b54d11e83cb620643ebb54e4eb7d71/regex-2025.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:087511f5c8b7dfbe3a03f5d5ad0c2a33861b1fc387f21f6f60825a44865a385a", size = 277737, upload-time = "2025-11-03T21:31:31.422Z" }, + { url = "https://files.pythonhosted.org/packages/f4/fc/6500eb39f5f76c5e47a398df82e6b535a5e345f839581012a418b16f9cc3/regex-2025.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:1ff0d190c7f68ae7769cd0313fe45820ba07ffebfddfaa89cc1eb70827ba0ddc", size = 270290, upload-time = "2025-11-03T21:31:33.041Z" }, + { url = "https://files.pythonhosted.org/packages/e8/74/18f04cb53e58e3fb107439699bd8375cf5a835eec81084e0bddbd122e4c2/regex-2025.11.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bc8ab71e2e31b16e40868a40a69007bc305e1109bd4658eb6cad007e0bf67c41", size = 489312, upload-time = "2025-11-03T21:31:34.343Z" }, + { url = "https://files.pythonhosted.org/packages/78/3f/37fcdd0d2b1e78909108a876580485ea37c91e1acf66d3bb8e736348f441/regex-2025.11.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:22b29dda7e1f7062a52359fca6e58e548e28c6686f205e780b02ad8ef710de36", size = 291256, upload-time = "2025-11-03T21:31:35.675Z" }, + { url = "https://files.pythonhosted.org/packages/bf/26/0a575f58eb23b7ebd67a45fccbc02ac030b737b896b7e7a909ffe43ffd6a/regex-2025.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3a91e4a29938bc1a082cc28fdea44be420bf2bebe2665343029723892eb073e1", size = 288921, upload-time = "2025-11-03T21:31:37.07Z" }, + { url = "https://files.pythonhosted.org/packages/ea/98/6a8dff667d1af907150432cf5abc05a17ccd32c72a3615410d5365ac167a/regex-2025.11.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08b884f4226602ad40c5d55f52bf91a9df30f513864e0054bad40c0e9cf1afb7", size = 798568, upload-time = "2025-11-03T21:31:38.784Z" }, + { url = "https://files.pythonhosted.org/packages/64/15/92c1db4fa4e12733dd5a526c2dd2b6edcbfe13257e135fc0f6c57f34c173/regex-2025.11.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3e0b11b2b2433d1c39c7c7a30e3f3d0aeeea44c2a8d0bae28f6b95f639927a69", size = 864165, upload-time = "2025-11-03T21:31:40.559Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e7/3ad7da8cdee1ce66c7cd37ab5ab05c463a86ffeb52b1a25fe7bd9293b36c/regex-2025.11.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87eb52a81ef58c7ba4d45c3ca74e12aa4b4e77816f72ca25258a85b3ea96cb48", size = 912182, upload-time = "2025-11-03T21:31:42.002Z" }, + { url = "https://files.pythonhosted.org/packages/84/bd/9ce9f629fcb714ffc2c3faf62b6766ecb7a585e1e885eb699bcf130a5209/regex-2025.11.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a12ab1f5c29b4e93db518f5e3872116b7e9b1646c9f9f426f777b50d44a09e8c", size = 803501, upload-time = "2025-11-03T21:31:43.815Z" }, + { url = "https://files.pythonhosted.org/packages/7c/0f/8dc2e4349d8e877283e6edd6c12bdcebc20f03744e86f197ab6e4492bf08/regex-2025.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7521684c8c7c4f6e88e35ec89680ee1aa8358d3f09d27dfbdf62c446f5d4c695", size = 787842, upload-time = "2025-11-03T21:31:45.353Z" }, + { url = "https://files.pythonhosted.org/packages/f9/73/cff02702960bc185164d5619c0c62a2f598a6abff6695d391b096237d4ab/regex-2025.11.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7fe6e5440584e94cc4b3f5f4d98a25e29ca12dccf8873679a635638349831b98", size = 858519, upload-time = "2025-11-03T21:31:46.814Z" }, + { url = "https://files.pythonhosted.org/packages/61/83/0e8d1ae71e15bc1dc36231c90b46ee35f9d52fab2e226b0e039e7ea9c10a/regex-2025.11.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:8e026094aa12b43f4fd74576714e987803a315c76edb6b098b9809db5de58f74", size = 850611, upload-time = "2025-11-03T21:31:48.289Z" }, + { url = "https://files.pythonhosted.org/packages/c8/f5/70a5cdd781dcfaa12556f2955bf170cd603cb1c96a1827479f8faea2df97/regex-2025.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:435bbad13e57eb5606a68443af62bed3556de2f46deb9f7d4237bc2f1c9fb3a0", size = 789759, upload-time = "2025-11-03T21:31:49.759Z" }, + { url = "https://files.pythonhosted.org/packages/59/9b/7c29be7903c318488983e7d97abcf8ebd3830e4c956c4c540005fcfb0462/regex-2025.11.3-cp312-cp312-win32.whl", hash = "sha256:3839967cf4dc4b985e1570fd8d91078f0c519f30491c60f9ac42a8db039be204", size = 266194, upload-time = "2025-11-03T21:31:51.53Z" }, + { url = "https://files.pythonhosted.org/packages/1a/67/3b92df89f179d7c367be654ab5626ae311cb28f7d5c237b6bb976cd5fbbb/regex-2025.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:e721d1b46e25c481dc5ded6f4b3f66c897c58d2e8cfdf77bbced84339108b0b9", size = 277069, upload-time = "2025-11-03T21:31:53.151Z" }, + { url = "https://files.pythonhosted.org/packages/d7/55/85ba4c066fe5094d35b249c3ce8df0ba623cfd35afb22d6764f23a52a1c5/regex-2025.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:64350685ff08b1d3a6fff33f45a9ca183dc1d58bbfe4981604e70ec9801bbc26", size = 270330, upload-time = "2025-11-03T21:31:54.514Z" }, + { url = "https://files.pythonhosted.org/packages/e1/a7/dda24ebd49da46a197436ad96378f17df30ceb40e52e859fc42cac45b850/regex-2025.11.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c1e448051717a334891f2b9a620fe36776ebf3dd8ec46a0b877c8ae69575feb4", size = 489081, upload-time = "2025-11-03T21:31:55.9Z" }, + { url = "https://files.pythonhosted.org/packages/19/22/af2dc751aacf88089836aa088a1a11c4f21a04707eb1b0478e8e8fb32847/regex-2025.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9b5aca4d5dfd7fbfbfbdaf44850fcc7709a01146a797536a8f84952e940cca76", size = 291123, upload-time = "2025-11-03T21:31:57.758Z" }, + { url = "https://files.pythonhosted.org/packages/a3/88/1a3ea5672f4b0a84802ee9891b86743438e7c04eb0b8f8c4e16a42375327/regex-2025.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:04d2765516395cf7dda331a244a3282c0f5ae96075f728629287dfa6f76ba70a", size = 288814, upload-time = "2025-11-03T21:32:01.12Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8c/f5987895bf42b8ddeea1b315c9fedcfe07cadee28b9c98cf50d00adcb14d/regex-2025.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d9903ca42bfeec4cebedba8022a7c97ad2aab22e09573ce9976ba01b65e4361", size = 798592, upload-time = "2025-11-03T21:32:03.006Z" }, + { url = "https://files.pythonhosted.org/packages/99/2a/6591ebeede78203fa77ee46a1c36649e02df9eaa77a033d1ccdf2fcd5d4e/regex-2025.11.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:639431bdc89d6429f6721625e8129413980ccd62e9d3f496be618a41d205f160", size = 864122, upload-time = "2025-11-03T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/94/d6/be32a87cf28cf8ed064ff281cfbd49aefd90242a83e4b08b5a86b38e8eb4/regex-2025.11.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f117efad42068f9715677c8523ed2be1518116d1c49b1dd17987716695181efe", size = 912272, upload-time = "2025-11-03T21:32:06.148Z" }, + { url = "https://files.pythonhosted.org/packages/62/11/9bcef2d1445665b180ac7f230406ad80671f0fc2a6ffb93493b5dd8cd64c/regex-2025.11.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4aecb6f461316adf9f1f0f6a4a1a3d79e045f9b71ec76055a791affa3b285850", size = 803497, upload-time = "2025-11-03T21:32:08.162Z" }, + { url = "https://files.pythonhosted.org/packages/e5/a7/da0dc273d57f560399aa16d8a68ae7f9b57679476fc7ace46501d455fe84/regex-2025.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3b3a5f320136873cc5561098dfab677eea139521cb9a9e8db98b7e64aef44cbc", size = 787892, upload-time = "2025-11-03T21:32:09.769Z" }, + { url = "https://files.pythonhosted.org/packages/da/4b/732a0c5a9736a0b8d6d720d4945a2f1e6f38f87f48f3173559f53e8d5d82/regex-2025.11.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:75fa6f0056e7efb1f42a1c34e58be24072cb9e61a601340cc1196ae92326a4f9", size = 858462, upload-time = "2025-11-03T21:32:11.769Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f5/a2a03df27dc4c2d0c769220f5110ba8c4084b0bfa9ab0f9b4fcfa3d2b0fc/regex-2025.11.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:dbe6095001465294f13f1adcd3311e50dd84e5a71525f20a10bd16689c61ce0b", size = 850528, upload-time = "2025-11-03T21:32:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/d6/09/e1cd5bee3841c7f6eb37d95ca91cdee7100b8f88b81e41c2ef426910891a/regex-2025.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:454d9b4ae7881afbc25015b8627c16d88a597479b9dea82b8c6e7e2e07240dc7", size = 789866, upload-time = "2025-11-03T21:32:15.748Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/702f5ea74e2a9c13d855a6a85b7f80c30f9e72a95493260193c07f3f8d74/regex-2025.11.3-cp313-cp313-win32.whl", hash = "sha256:28ba4d69171fc6e9896337d4fc63a43660002b7da53fc15ac992abcf3410917c", size = 266189, upload-time = "2025-11-03T21:32:17.493Z" }, + { url = "https://files.pythonhosted.org/packages/8b/00/6e29bb314e271a743170e53649db0fdb8e8ff0b64b4f425f5602f4eb9014/regex-2025.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:bac4200befe50c670c405dc33af26dad5a3b6b255dd6c000d92fe4629f9ed6a5", size = 277054, upload-time = "2025-11-03T21:32:19.042Z" }, + { url = "https://files.pythonhosted.org/packages/25/f1/b156ff9f2ec9ac441710764dda95e4edaf5f36aca48246d1eea3f1fd96ec/regex-2025.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:2292cd5a90dab247f9abe892ac584cb24f0f54680c73fcb4a7493c66c2bf2467", size = 270325, upload-time = "2025-11-03T21:32:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/20/28/fd0c63357caefe5680b8ea052131acbd7f456893b69cc2a90cc3e0dc90d4/regex-2025.11.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:1eb1ebf6822b756c723e09f5186473d93236c06c579d2cc0671a722d2ab14281", size = 491984, upload-time = "2025-11-03T21:32:23.466Z" }, + { url = "https://files.pythonhosted.org/packages/df/ec/7014c15626ab46b902b3bcc4b28a7bae46d8f281fc7ea9c95e22fcaaa917/regex-2025.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1e00ec2970aab10dc5db34af535f21fcf32b4a31d99e34963419636e2f85ae39", size = 292673, upload-time = "2025-11-03T21:32:25.034Z" }, + { url = "https://files.pythonhosted.org/packages/23/ab/3b952ff7239f20d05f1f99e9e20188513905f218c81d52fb5e78d2bf7634/regex-2025.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a4cb042b615245d5ff9b3794f56be4138b5adc35a4166014d31d1814744148c7", size = 291029, upload-time = "2025-11-03T21:32:26.528Z" }, + { url = "https://files.pythonhosted.org/packages/21/7e/3dc2749fc684f455f162dcafb8a187b559e2614f3826877d3844a131f37b/regex-2025.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44f264d4bf02f3176467d90b294d59bf1db9fe53c141ff772f27a8b456b2a9ed", size = 807437, upload-time = "2025-11-03T21:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/1b/0b/d529a85ab349c6a25d1ca783235b6e3eedf187247eab536797021f7126c6/regex-2025.11.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7be0277469bf3bd7a34a9c57c1b6a724532a0d235cd0dc4e7f4316f982c28b19", size = 873368, upload-time = "2025-11-03T21:32:30.4Z" }, + { url = "https://files.pythonhosted.org/packages/7d/18/2d868155f8c9e3e9d8f9e10c64e9a9f496bb8f7e037a88a8bed26b435af6/regex-2025.11.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0d31e08426ff4b5b650f68839f5af51a92a5b51abd8554a60c2fbc7c71f25d0b", size = 914921, upload-time = "2025-11-03T21:32:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/2d/71/9d72ff0f354fa783fe2ba913c8734c3b433b86406117a8db4ea2bf1c7a2f/regex-2025.11.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e43586ce5bd28f9f285a6e729466841368c4a0353f6fd08d4ce4630843d3648a", size = 812708, upload-time = "2025-11-03T21:32:34.305Z" }, + { url = "https://files.pythonhosted.org/packages/e7/19/ce4bf7f5575c97f82b6e804ffb5c4e940c62609ab2a0d9538d47a7fdf7d4/regex-2025.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0f9397d561a4c16829d4e6ff75202c1c08b68a3bdbfe29dbfcdb31c9830907c6", size = 795472, upload-time = "2025-11-03T21:32:36.364Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/fd1063a176ffb7b2315f9a1b08d17b18118b28d9df163132615b835a26ee/regex-2025.11.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:dd16e78eb18ffdb25ee33a0682d17912e8cc8a770e885aeee95020046128f1ce", size = 868341, upload-time = "2025-11-03T21:32:38.042Z" }, + { url = "https://files.pythonhosted.org/packages/12/43/103fb2e9811205e7386366501bc866a164a0430c79dd59eac886a2822950/regex-2025.11.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:ffcca5b9efe948ba0661e9df0fa50d2bc4b097c70b9810212d6b62f05d83b2dd", size = 854666, upload-time = "2025-11-03T21:32:40.079Z" }, + { url = "https://files.pythonhosted.org/packages/7d/22/e392e53f3869b75804762c7c848bd2dd2abf2b70fb0e526f58724638bd35/regex-2025.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c56b4d162ca2b43318ac671c65bd4d563e841a694ac70e1a976ac38fcf4ca1d2", size = 799473, upload-time = "2025-11-03T21:32:42.148Z" }, + { url = "https://files.pythonhosted.org/packages/4f/f9/8bd6b656592f925b6845fcbb4d57603a3ac2fb2373344ffa1ed70aa6820a/regex-2025.11.3-cp313-cp313t-win32.whl", hash = "sha256:9ddc42e68114e161e51e272f667d640f97e84a2b9ef14b7477c53aac20c2d59a", size = 268792, upload-time = "2025-11-03T21:32:44.13Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/0e7d603467775ff65cd2aeabf1b5b50cc1c3708556a8b849a2fa4dd1542b/regex-2025.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:7a7c7fdf755032ffdd72c77e3d8096bdcb0eb92e89e17571a196f03d88b11b3c", size = 280214, upload-time = "2025-11-03T21:32:45.853Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d0/2afc6f8e94e2b64bfb738a7c2b6387ac1699f09f032d363ed9447fd2bb57/regex-2025.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:df9eb838c44f570283712e7cff14c16329a9f0fb19ca492d21d4b7528ee6821e", size = 271469, upload-time = "2025-11-03T21:32:48.026Z" }, + { url = "https://files.pythonhosted.org/packages/31/e9/f6e13de7e0983837f7b6d238ad9458800a874bf37c264f7923e63409944c/regex-2025.11.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9697a52e57576c83139d7c6f213d64485d3df5bf84807c35fa409e6c970801c6", size = 489089, upload-time = "2025-11-03T21:32:50.027Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5c/261f4a262f1fa65141c1b74b255988bd2fa020cc599e53b080667d591cfc/regex-2025.11.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e18bc3f73bd41243c9b38a6d9f2366cd0e0137a9aebe2d8ff76c5b67d4c0a3f4", size = 291059, upload-time = "2025-11-03T21:32:51.682Z" }, + { url = "https://files.pythonhosted.org/packages/8e/57/f14eeb7f072b0e9a5a090d1712741fd8f214ec193dba773cf5410108bb7d/regex-2025.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:61a08bcb0ec14ff4e0ed2044aad948d0659604f824cbd50b55e30b0ec6f09c73", size = 288900, upload-time = "2025-11-03T21:32:53.569Z" }, + { url = "https://files.pythonhosted.org/packages/3c/6b/1d650c45e99a9b327586739d926a1cd4e94666b1bd4af90428b36af66dc7/regex-2025.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9c30003b9347c24bcc210958c5d167b9e4f9be786cb380a7d32f14f9b84674f", size = 799010, upload-time = "2025-11-03T21:32:55.222Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/d66dcbc6b628ce4e3f7f0cbbb84603aa2fc0ffc878babc857726b8aab2e9/regex-2025.11.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4e1e592789704459900728d88d41a46fe3969b82ab62945560a31732ffc19a6d", size = 864893, upload-time = "2025-11-03T21:32:57.239Z" }, + { url = "https://files.pythonhosted.org/packages/bf/2d/f238229f1caba7ac87a6c4153d79947fb0261415827ae0f77c304260c7d3/regex-2025.11.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6538241f45eb5a25aa575dbba1069ad786f68a4f2773a29a2bd3dd1f9de787be", size = 911522, upload-time = "2025-11-03T21:32:59.274Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3d/22a4eaba214a917c80e04f6025d26143690f0419511e0116508e24b11c9b/regex-2025.11.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce22519c989bb72a7e6b36a199384c53db7722fe669ba891da75907fe3587db", size = 803272, upload-time = "2025-11-03T21:33:01.393Z" }, + { url = "https://files.pythonhosted.org/packages/84/b1/03188f634a409353a84b5ef49754b97dbcc0c0f6fd6c8ede505a8960a0a4/regex-2025.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:66d559b21d3640203ab9075797a55165d79017520685fb407b9234d72ab63c62", size = 787958, upload-time = "2025-11-03T21:33:03.379Z" }, + { url = "https://files.pythonhosted.org/packages/99/6a/27d072f7fbf6fadd59c64d210305e1ff865cc3b78b526fd147db768c553b/regex-2025.11.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:669dcfb2e38f9e8c69507bace46f4889e3abbfd9b0c29719202883c0a603598f", size = 859289, upload-time = "2025-11-03T21:33:05.374Z" }, + { url = "https://files.pythonhosted.org/packages/9a/70/1b3878f648e0b6abe023172dacb02157e685564853cc363d9961bcccde4e/regex-2025.11.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:32f74f35ff0f25a5021373ac61442edcb150731fbaa28286bbc8bb1582c89d02", size = 850026, upload-time = "2025-11-03T21:33:07.131Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d5/68e25559b526b8baab8e66839304ede68ff6727237a47727d240006bd0ff/regex-2025.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e6c7a21dffba883234baefe91bc3388e629779582038f75d2a5be918e250f0ed", size = 789499, upload-time = "2025-11-03T21:33:09.141Z" }, + { url = "https://files.pythonhosted.org/packages/fc/df/43971264857140a350910d4e33df725e8c94dd9dee8d2e4729fa0d63d49e/regex-2025.11.3-cp314-cp314-win32.whl", hash = "sha256:795ea137b1d809eb6836b43748b12634291c0ed55ad50a7d72d21edf1cd565c4", size = 271604, upload-time = "2025-11-03T21:33:10.9Z" }, + { url = "https://files.pythonhosted.org/packages/01/6f/9711b57dc6894a55faf80a4c1b5aa4f8649805cb9c7aef46f7d27e2b9206/regex-2025.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f95fbaa0ee1610ec0fc6b26668e9917a582ba80c52cc6d9ada15e30aa9ab9ad", size = 280320, upload-time = "2025-11-03T21:33:12.572Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7e/f6eaa207d4377481f5e1775cdeb5a443b5a59b392d0065f3417d31d80f87/regex-2025.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:dfec44d532be4c07088c3de2876130ff0fbeeacaa89a137decbbb5f665855a0f", size = 273372, upload-time = "2025-11-03T21:33:14.219Z" }, + { url = "https://files.pythonhosted.org/packages/c3/06/49b198550ee0f5e4184271cee87ba4dfd9692c91ec55289e6282f0f86ccf/regex-2025.11.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ba0d8a5d7f04f73ee7d01d974d47c5834f8a1b0224390e4fe7c12a3a92a78ecc", size = 491985, upload-time = "2025-11-03T21:33:16.555Z" }, + { url = "https://files.pythonhosted.org/packages/ce/bf/abdafade008f0b1c9da10d934034cb670432d6cf6cbe38bbb53a1cfd6cf8/regex-2025.11.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:442d86cf1cfe4faabf97db7d901ef58347efd004934da045c745e7b5bd57ac49", size = 292669, upload-time = "2025-11-03T21:33:18.32Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ef/0c357bb8edbd2ad8e273fcb9e1761bc37b8acbc6e1be050bebd6475f19c1/regex-2025.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fd0a5e563c756de210bb964789b5abe4f114dacae9104a47e1a649b910361536", size = 291030, upload-time = "2025-11-03T21:33:20.048Z" }, + { url = "https://files.pythonhosted.org/packages/79/06/edbb67257596649b8fb088d6aeacbcb248ac195714b18a65e018bf4c0b50/regex-2025.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf3490bcbb985a1ae97b2ce9ad1c0f06a852d5b19dde9b07bdf25bf224248c95", size = 807674, upload-time = "2025-11-03T21:33:21.797Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d9/ad4deccfce0ea336296bd087f1a191543bb99ee1c53093dcd4c64d951d00/regex-2025.11.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3809988f0a8b8c9dcc0f92478d6501fac7200b9ec56aecf0ec21f4a2ec4b6009", size = 873451, upload-time = "2025-11-03T21:33:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/13/75/a55a4724c56ef13e3e04acaab29df26582f6978c000ac9cd6810ad1f341f/regex-2025.11.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f4ff94e58e84aedb9c9fce66d4ef9f27a190285b451420f297c9a09f2b9abee9", size = 914980, upload-time = "2025-11-03T21:33:25.999Z" }, + { url = "https://files.pythonhosted.org/packages/67/1e/a1657ee15bd9116f70d4a530c736983eed997b361e20ecd8f5ca3759d5c5/regex-2025.11.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eb542fd347ce61e1321b0a6b945d5701528dca0cd9759c2e3bb8bd57e47964d", size = 812852, upload-time = "2025-11-03T21:33:27.852Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6f/f7516dde5506a588a561d296b2d0044839de06035bb486b326065b4c101e/regex-2025.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2d5919075a1f2e413c00b056ea0c2f065b3f5fe83c3d07d325ab92dce51d6", size = 795566, upload-time = "2025-11-03T21:33:32.364Z" }, + { url = "https://files.pythonhosted.org/packages/d9/dd/3d10b9e170cc16fb34cb2cef91513cf3df65f440b3366030631b2984a264/regex-2025.11.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3f8bf11a4827cc7ce5a53d4ef6cddd5ad25595d3c1435ef08f76825851343154", size = 868463, upload-time = "2025-11-03T21:33:34.459Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8e/935e6beff1695aa9085ff83195daccd72acc82c81793df480f34569330de/regex-2025.11.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:22c12d837298651e5550ac1d964e4ff57c3f56965fc1812c90c9fb2028eaf267", size = 854694, upload-time = "2025-11-03T21:33:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/92/12/10650181a040978b2f5720a6a74d44f841371a3d984c2083fc1752e4acf6/regex-2025.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ba394a3dda9ad41c7c780f60f6e4a70988741415ae96f6d1bf6c239cf01379", size = 799691, upload-time = "2025-11-03T21:33:39.079Z" }, + { url = "https://files.pythonhosted.org/packages/67/90/8f37138181c9a7690e7e4cb388debbd389342db3c7381d636d2875940752/regex-2025.11.3-cp314-cp314t-win32.whl", hash = "sha256:4bf146dca15cdd53224a1bf46d628bd7590e4a07fbb69e720d561aea43a32b38", size = 274583, upload-time = "2025-11-03T21:33:41.302Z" }, + { url = "https://files.pythonhosted.org/packages/8f/cd/867f5ec442d56beb56f5f854f40abcfc75e11d10b11fdb1869dd39c63aaf/regex-2025.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:adad1a1bcf1c9e76346e091d22d23ac54ef28e1365117d99521631078dfec9de", size = 284286, upload-time = "2025-11-03T21:33:43.324Z" }, + { url = "https://files.pythonhosted.org/packages/20/31/32c0c4610cbc070362bf1d2e4ea86d1ea29014d400a6d6c2486fcfd57766/regex-2025.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:c54f768482cef41e219720013cd05933b6f971d9562544d691c68699bf2b6801", size = 274741, upload-time = "2025-11-03T21:33:45.557Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rich" +version = "13.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149, upload-time = "2024-11-01T16:43:57.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424, upload-time = "2024-11-01T16:43:55.817Z" }, +] + +[[package]] +name = "rich-click" +version = "1.9.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/27/091e140ea834272188e63f8dd6faac1f5c687582b687197b3e0ec3c78ebf/rich_click-1.9.7.tar.gz", hash = "sha256:022997c1e30731995bdbc8ec2f82819340d42543237f033a003c7b1f843fc5dc", size = 74838, upload-time = "2026-01-31T04:29:27.707Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/e5/d708d262b600a352abe01c2ae360d8ff75b0af819b78e9af293191d928e6/rich_click-1.9.7-py3-none-any.whl", hash = "sha256:2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b", size = 71491, upload-time = "2026-01-31T04:29:26.777Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/33/23b3b3419b6a3e0f559c7c0d2ca8fc1b9448382b25245033788785921332/rpds_py-0.29.0.tar.gz", hash = "sha256:fe55fe686908f50154d1dc599232016e50c243b438c3b7432f24e2895b0e5359", size = 69359, upload-time = "2025-11-16T14:50:39.532Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/ab/7fb95163a53ab122c74a7c42d2d2f012819af2cf3deb43fb0d5acf45cc1a/rpds_py-0.29.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9b9c764a11fd637e0322a488560533112837f5334ffeb48b1be20f6d98a7b437", size = 372344, upload-time = "2025-11-16T14:47:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/b3/45/f3c30084c03b0d0f918cb4c5ae2c20b0a148b51ba2b3f6456765b629bedd/rpds_py-0.29.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3fd2164d73812026ce970d44c3ebd51e019d2a26a4425a5dcbdfa93a34abc383", size = 363041, upload-time = "2025-11-16T14:47:58.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e9/4d044a1662608c47a87cbb37b999d4d5af54c6d6ebdda93a4d8bbf8b2a10/rpds_py-0.29.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a097b7f7f7274164566ae90a221fd725363c0e9d243e2e9ed43d195ccc5495c", size = 391775, upload-time = "2025-11-16T14:48:00.197Z" }, + { url = "https://files.pythonhosted.org/packages/50/c9/7616d3ace4e6731aeb6e3cd85123e03aec58e439044e214b9c5c60fd8eb1/rpds_py-0.29.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7cdc0490374e31cedefefaa1520d5fe38e82fde8748cbc926e7284574c714d6b", size = 405624, upload-time = "2025-11-16T14:48:01.496Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e2/6d7d6941ca0843609fd2d72c966a438d6f22617baf22d46c3d2156c31350/rpds_py-0.29.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89ca2e673ddd5bde9b386da9a0aac0cab0e76f40c8f0aaf0d6311b6bbf2aa311", size = 527894, upload-time = "2025-11-16T14:48:03.167Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f7/aee14dc2db61bb2ae1e3068f134ca9da5f28c586120889a70ff504bb026f/rpds_py-0.29.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5d9da3ff5af1ca1249b1adb8ef0573b94c76e6ae880ba1852f033bf429d4588", size = 412720, upload-time = "2025-11-16T14:48:04.413Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e2/2293f236e887c0360c2723d90c00d48dee296406994d6271faf1712e94ec/rpds_py-0.29.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8238d1d310283e87376c12f658b61e1ee23a14c0e54c7c0ce953efdbdc72deed", size = 392945, upload-time = "2025-11-16T14:48:06.252Z" }, + { url = "https://files.pythonhosted.org/packages/14/cd/ceea6147acd3bd1fd028d1975228f08ff19d62098078d5ec3eed49703797/rpds_py-0.29.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:2d6fb2ad1c36f91c4646989811e84b1ea5e0c3cf9690b826b6e32b7965853a63", size = 406385, upload-time = "2025-11-16T14:48:07.575Z" }, + { url = "https://files.pythonhosted.org/packages/52/36/fe4dead19e45eb77a0524acfdbf51e6cda597b26fc5b6dddbff55fbbb1a5/rpds_py-0.29.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:534dc9df211387547267ccdb42253aa30527482acb38dd9b21c5c115d66a96d2", size = 423943, upload-time = "2025-11-16T14:48:10.175Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7b/4551510803b582fa4abbc8645441a2d15aa0c962c3b21ebb380b7e74f6a1/rpds_py-0.29.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d456e64724a075441e4ed648d7f154dc62e9aabff29bcdf723d0c00e9e1d352f", size = 574204, upload-time = "2025-11-16T14:48:11.499Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/071ccdd7b171e727a6ae079f02c26f75790b41555f12ca8f1151336d2124/rpds_py-0.29.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a738f2da2f565989401bd6fd0b15990a4d1523c6d7fe83f300b7e7d17212feca", size = 600587, upload-time = "2025-11-16T14:48:12.822Z" }, + { url = "https://files.pythonhosted.org/packages/03/09/96983d48c8cf5a1e03c7d9cc1f4b48266adfb858ae48c7c2ce978dbba349/rpds_py-0.29.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a110e14508fd26fd2e472bb541f37c209409876ba601cf57e739e87d8a53cf95", size = 562287, upload-time = "2025-11-16T14:48:14.108Z" }, + { url = "https://files.pythonhosted.org/packages/40/f0/8c01aaedc0fa92156f0391f39ea93b5952bc0ec56b897763858f95da8168/rpds_py-0.29.0-cp311-cp311-win32.whl", hash = "sha256:923248a56dd8d158389a28934f6f69ebf89f218ef96a6b216a9be6861804d3f4", size = 221394, upload-time = "2025-11-16T14:48:15.374Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a5/a8b21c54c7d234efdc83dc034a4d7cd9668e3613b6316876a29b49dece71/rpds_py-0.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:539eb77eb043afcc45314d1be09ea6d6cafb3addc73e0547c171c6d636957f60", size = 235713, upload-time = "2025-11-16T14:48:16.636Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1f/df3c56219523947b1be402fa12e6323fe6d61d883cf35d6cb5d5bb6db9d9/rpds_py-0.29.0-cp311-cp311-win_arm64.whl", hash = "sha256:bdb67151ea81fcf02d8f494703fb728d4d34d24556cbff5f417d74f6f5792e7c", size = 229157, upload-time = "2025-11-16T14:48:17.891Z" }, + { url = "https://files.pythonhosted.org/packages/3c/50/bc0e6e736d94e420df79be4deb5c9476b63165c87bb8f19ef75d100d21b3/rpds_py-0.29.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0891cfd8db43e085c0ab93ab7e9b0c8fee84780d436d3b266b113e51e79f954", size = 376000, upload-time = "2025-11-16T14:48:19.141Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3a/46676277160f014ae95f24de53bed0e3b7ea66c235e7de0b9df7bd5d68ba/rpds_py-0.29.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3897924d3f9a0361472d884051f9a2460358f9a45b1d85a39a158d2f8f1ad71c", size = 360575, upload-time = "2025-11-16T14:48:20.443Z" }, + { url = "https://files.pythonhosted.org/packages/75/ba/411d414ed99ea1afdd185bbabeeaac00624bd1e4b22840b5e9967ade6337/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a21deb8e0d1571508c6491ce5ea5e25669b1dd4adf1c9d64b6314842f708b5d", size = 392159, upload-time = "2025-11-16T14:48:22.12Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b1/e18aa3a331f705467a48d0296778dc1fea9d7f6cf675bd261f9a846c7e90/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9efe71687d6427737a0a2de9ca1c0a216510e6cd08925c44162be23ed7bed2d5", size = 410602, upload-time = "2025-11-16T14:48:23.563Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6c/04f27f0c9f2299274c76612ac9d2c36c5048bb2c6c2e52c38c60bf3868d9/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:40f65470919dc189c833e86b2c4bd21bd355f98436a2cef9e0a9a92aebc8e57e", size = 515808, upload-time = "2025-11-16T14:48:24.949Z" }, + { url = "https://files.pythonhosted.org/packages/83/56/a8412aa464fb151f8bc0d91fb0bb888adc9039bd41c1c6ba8d94990d8cf8/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:def48ff59f181130f1a2cb7c517d16328efac3ec03951cca40c1dc2049747e83", size = 416015, upload-time = "2025-11-16T14:48:26.782Z" }, + { url = "https://files.pythonhosted.org/packages/04/4c/f9b8a05faca3d9e0a6397c90d13acb9307c9792b2bff621430c58b1d6e76/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad7bd570be92695d89285a4b373006930715b78d96449f686af422debb4d3949", size = 395325, upload-time = "2025-11-16T14:48:28.055Z" }, + { url = "https://files.pythonhosted.org/packages/34/60/869f3bfbf8ed7b54f1ad9a5543e0fdffdd40b5a8f587fe300ee7b4f19340/rpds_py-0.29.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:5a572911cd053137bbff8e3a52d31c5d2dba51d3a67ad902629c70185f3f2181", size = 410160, upload-time = "2025-11-16T14:48:29.338Z" }, + { url = "https://files.pythonhosted.org/packages/91/aa/e5b496334e3aba4fe4c8a80187b89f3c1294c5c36f2a926da74338fa5a73/rpds_py-0.29.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d583d4403bcbf10cffc3ab5cee23d7643fcc960dff85973fd3c2d6c86e8dbb0c", size = 425309, upload-time = "2025-11-16T14:48:30.691Z" }, + { url = "https://files.pythonhosted.org/packages/85/68/4e24a34189751ceb6d66b28f18159922828dd84155876551f7ca5b25f14f/rpds_py-0.29.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:070befbb868f257d24c3bb350dbd6e2f645e83731f31264b19d7231dd5c396c7", size = 574644, upload-time = "2025-11-16T14:48:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/474a005ea4ea9c3b4f17b6108b6b13cebfc98ebaff11d6e1b193204b3a93/rpds_py-0.29.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fc935f6b20b0c9f919a8ff024739174522abd331978f750a74bb68abd117bd19", size = 601605, upload-time = "2025-11-16T14:48:33.252Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b1/c56f6a9ab8c5f6bb5c65c4b5f8229167a3a525245b0773f2c0896686b64e/rpds_py-0.29.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8c5a8ecaa44ce2d8d9d20a68a2483a74c07f05d72e94a4dff88906c8807e77b0", size = 564593, upload-time = "2025-11-16T14:48:34.643Z" }, + { url = "https://files.pythonhosted.org/packages/b3/13/0494cecce4848f68501e0a229432620b4b57022388b071eeff95f3e1e75b/rpds_py-0.29.0-cp312-cp312-win32.whl", hash = "sha256:ba5e1aeaf8dd6d8f6caba1f5539cddda87d511331714b7b5fc908b6cfc3636b7", size = 223853, upload-time = "2025-11-16T14:48:36.419Z" }, + { url = "https://files.pythonhosted.org/packages/1f/6a/51e9aeb444a00cdc520b032a28b07e5f8dc7bc328b57760c53e7f96997b4/rpds_py-0.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:b5f6134faf54b3cb83375db0f113506f8b7770785be1f95a631e7e2892101977", size = 239895, upload-time = "2025-11-16T14:48:37.956Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d4/8bce56cdad1ab873e3f27cb31c6a51d8f384d66b022b820525b879f8bed1/rpds_py-0.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:b016eddf00dca7944721bf0cd85b6af7f6c4efaf83ee0b37c4133bd39757a8c7", size = 230321, upload-time = "2025-11-16T14:48:39.71Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d9/c5de60d9d371bbb186c3e9bf75f4fc5665e11117a25a06a6b2e0afb7380e/rpds_py-0.29.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1585648d0760b88292eecab5181f5651111a69d90eff35d6b78aa32998886a61", size = 375710, upload-time = "2025-11-16T14:48:41.063Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b3/0860cdd012291dc21272895ce107f1e98e335509ba986dd83d72658b82b9/rpds_py-0.29.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:521807963971a23996ddaf764c682b3e46459b3c58ccd79fefbe16718db43154", size = 360582, upload-time = "2025-11-16T14:48:42.423Z" }, + { url = "https://files.pythonhosted.org/packages/92/8a/a18c2f4a61b3407e56175f6aab6deacdf9d360191a3d6f38566e1eaf7266/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a8896986efaa243ab713c69e6491a4138410f0fe36f2f4c71e18bd5501e8014", size = 391172, upload-time = "2025-11-16T14:48:43.75Z" }, + { url = "https://files.pythonhosted.org/packages/fd/49/e93354258508c50abc15cdcd5fcf7ac4117f67bb6233ad7859f75e7372a0/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1d24564a700ef41480a984c5ebed62b74e6ce5860429b98b1fede76049e953e6", size = 409586, upload-time = "2025-11-16T14:48:45.498Z" }, + { url = "https://files.pythonhosted.org/packages/5a/8d/a27860dae1c19a6bdc901f90c81f0d581df1943355802961a57cdb5b6cd1/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6596b93c010d386ae46c9fba9bfc9fc5965fa8228edeac51576299182c2e31c", size = 516339, upload-time = "2025-11-16T14:48:47.308Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/a75e603161e79b7110c647163d130872b271c6b28712c803c65d492100f7/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5cc58aac218826d054c7da7f95821eba94125d88be673ff44267bb89d12a5866", size = 416201, upload-time = "2025-11-16T14:48:48.615Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/555b4ee17508beafac135c8b450816ace5a96194ce97fefc49d58e5652ea/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de73e40ebc04dd5d9556f50180395322193a78ec247e637e741c1b954810f295", size = 395095, upload-time = "2025-11-16T14:48:50.027Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f0/c90b671b9031e800ec45112be42ea9f027f94f9ac25faaac8770596a16a1/rpds_py-0.29.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:295ce5ac7f0cf69a651ea75c8f76d02a31f98e5698e82a50a5f4d4982fbbae3b", size = 410077, upload-time = "2025-11-16T14:48:51.515Z" }, + { url = "https://files.pythonhosted.org/packages/3d/80/9af8b640b81fe21e6f718e9dec36c0b5f670332747243130a5490f292245/rpds_py-0.29.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1ea59b23ea931d494459c8338056fe7d93458c0bf3ecc061cd03916505369d55", size = 424548, upload-time = "2025-11-16T14:48:53.237Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0b/b5647446e991736e6a495ef510e6710df91e880575a586e763baeb0aa770/rpds_py-0.29.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f49d41559cebd608042fdcf54ba597a4a7555b49ad5c1c0c03e0af82692661cd", size = 573661, upload-time = "2025-11-16T14:48:54.769Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b3/1b1c9576839ff583d1428efbf59f9ee70498d8ce6c0b328ac02f1e470879/rpds_py-0.29.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:05a2bd42768ea988294ca328206efbcc66e220d2d9b7836ee5712c07ad6340ea", size = 600937, upload-time = "2025-11-16T14:48:56.247Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7b/b6cfca2f9fee4c4494ce54f7fb1b9f578867495a9aa9fc0d44f5f735c8e0/rpds_py-0.29.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33ca7bdfedd83339ca55da3a5e1527ee5870d4b8369456b5777b197756f3ca22", size = 564496, upload-time = "2025-11-16T14:48:57.691Z" }, + { url = "https://files.pythonhosted.org/packages/b9/fb/ba29ec7f0f06eb801bac5a23057a9ff7670623b5e8013bd59bec4aa09de8/rpds_py-0.29.0-cp313-cp313-win32.whl", hash = "sha256:20c51ae86a0bb9accc9ad4e6cdeec58d5ebb7f1b09dd4466331fc65e1766aae7", size = 223126, upload-time = "2025-11-16T14:48:59.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/6b/0229d3bed4ddaa409e6d90b0ae967ed4380e4bdd0dad6e59b92c17d42457/rpds_py-0.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:6410e66f02803600edb0b1889541f4b5cc298a5ccda0ad789cc50ef23b54813e", size = 239771, upload-time = "2025-11-16T14:49:00.872Z" }, + { url = "https://files.pythonhosted.org/packages/e4/38/d2868f058b164f8efd89754d85d7b1c08b454f5c07ac2e6cc2e9bd4bd05b/rpds_py-0.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:56838e1cd9174dc23c5691ee29f1d1be9eab357f27efef6bded1328b23e1ced2", size = 229994, upload-time = "2025-11-16T14:49:02.673Z" }, + { url = "https://files.pythonhosted.org/packages/52/91/5de91c5ec7d41759beec9b251630824dbb8e32d20c3756da1a9a9d309709/rpds_py-0.29.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:37d94eadf764d16b9a04307f2ab1d7af6dc28774bbe0535c9323101e14877b4c", size = 365886, upload-time = "2025-11-16T14:49:04.133Z" }, + { url = "https://files.pythonhosted.org/packages/85/7c/415d8c1b016d5f47ecec5145d9d6d21002d39dce8761b30f6c88810b455a/rpds_py-0.29.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d472cf73efe5726a067dce63eebe8215b14beabea7c12606fd9994267b3cfe2b", size = 355262, upload-time = "2025-11-16T14:49:05.543Z" }, + { url = "https://files.pythonhosted.org/packages/3d/14/bf83e2daa4f980e4dc848aed9299792a8b84af95e12541d9e7562f84a6ef/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72fdfd5ff8992e4636621826371e3ac5f3e3b8323e9d0e48378e9c13c3dac9d0", size = 384826, upload-time = "2025-11-16T14:49:07.301Z" }, + { url = "https://files.pythonhosted.org/packages/33/b8/53330c50a810ae22b4fbba5e6cf961b68b9d72d9bd6780a7c0a79b070857/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2549d833abdf8275c901313b9e8ff8fba57e50f6a495035a2a4e30621a2f7cc4", size = 394234, upload-time = "2025-11-16T14:49:08.782Z" }, + { url = "https://files.pythonhosted.org/packages/cc/32/01e2e9645cef0e584f518cfde4567563e57db2257244632b603f61b40e50/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4448dad428f28a6a767c3e3b80cde3446a22a0efbddaa2360f4bb4dc836d0688", size = 520008, upload-time = "2025-11-16T14:49:10.253Z" }, + { url = "https://files.pythonhosted.org/packages/98/c3/0d1b95a81affae2b10f950782e33a1fd2edd6ce2a479966cac98c9a66f57/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:115f48170fd4296a33938d8c11f697f5f26e0472e43d28f35624764173a60e4d", size = 409569, upload-time = "2025-11-16T14:49:12.478Z" }, + { url = "https://files.pythonhosted.org/packages/fa/60/aa3b8678f3f009f675b99174fa2754302a7fbfe749162e8043d111de2d88/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e5bb73ffc029820f4348e9b66b3027493ae00bca6629129cd433fd7a76308ee", size = 385188, upload-time = "2025-11-16T14:49:13.88Z" }, + { url = "https://files.pythonhosted.org/packages/92/02/5546c1c8aa89c18d40c1fcffdcc957ba730dee53fb7c3ca3a46f114761d2/rpds_py-0.29.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b1581fcde18fcdf42ea2403a16a6b646f8eb1e58d7f90a0ce693da441f76942e", size = 398587, upload-time = "2025-11-16T14:49:15.339Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e0/ad6eeaf47e236eba052fa34c4073078b9e092bd44da6bbb35aaae9580669/rpds_py-0.29.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16e9da2bda9eb17ea318b4c335ec9ac1818e88922cbe03a5743ea0da9ecf74fb", size = 416641, upload-time = "2025-11-16T14:49:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/1a/93/0acedfd50ad9cdd3879c615a6dc8c5f1ce78d2fdf8b87727468bb5bb4077/rpds_py-0.29.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:28fd300326dd21198f311534bdb6d7e989dd09b3418b3a91d54a0f384c700967", size = 566683, upload-time = "2025-11-16T14:49:18.342Z" }, + { url = "https://files.pythonhosted.org/packages/62/53/8c64e0f340a9e801459fc6456821abc15b3582cb5dc3932d48705a9d9ac7/rpds_py-0.29.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2aba991e041d031c7939e1358f583ae405a7bf04804ca806b97a5c0e0af1ea5e", size = 592730, upload-time = "2025-11-16T14:49:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/85/ef/3109b6584f8c4b0d2490747c916df833c127ecfa82be04d9a40a376f2090/rpds_py-0.29.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f437026dbbc3f08c99cc41a5b2570c6e1a1ddbe48ab19a9b814254128d4ea7a", size = 557361, upload-time = "2025-11-16T14:49:21.574Z" }, + { url = "https://files.pythonhosted.org/packages/ff/3b/61586475e82d57f01da2c16edb9115a618afe00ce86fe1b58936880b15af/rpds_py-0.29.0-cp313-cp313t-win32.whl", hash = "sha256:6e97846e9800a5d0fe7be4d008f0c93d0feeb2700da7b1f7528dabafb31dfadb", size = 211227, upload-time = "2025-11-16T14:49:23.03Z" }, + { url = "https://files.pythonhosted.org/packages/3b/3a/12dc43f13594a54ea0c9d7e9d43002116557330e3ad45bc56097ddf266e2/rpds_py-0.29.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f49196aec7c4b406495f60e6f947ad71f317a765f956d74bbd83996b9edc0352", size = 225248, upload-time = "2025-11-16T14:49:24.841Z" }, + { url = "https://files.pythonhosted.org/packages/89/b1/0b1474e7899371d9540d3bbb2a499a3427ae1fc39c998563fe9035a1073b/rpds_py-0.29.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:394d27e4453d3b4d82bb85665dc1fcf4b0badc30fc84282defed71643b50e1a1", size = 363731, upload-time = "2025-11-16T14:49:26.683Z" }, + { url = "https://files.pythonhosted.org/packages/28/12/3b7cf2068d0a334ed1d7b385a9c3c8509f4c2bcba3d4648ea71369de0881/rpds_py-0.29.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55d827b2ae95425d3be9bc9a5838b6c29d664924f98146557f7715e331d06df8", size = 354343, upload-time = "2025-11-16T14:49:28.24Z" }, + { url = "https://files.pythonhosted.org/packages/eb/73/5afcf8924bc02a749416eda64e17ac9c9b28f825f4737385295a0e99b0c1/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc31a07ed352e5462d3ee1b22e89285f4ce97d5266f6d1169da1142e78045626", size = 385406, upload-time = "2025-11-16T14:49:29.943Z" }, + { url = "https://files.pythonhosted.org/packages/c8/37/5db736730662508535221737a21563591b6f43c77f2e388951c42f143242/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c4695dd224212f6105db7ea62197144230b808d6b2bba52238906a2762f1d1e7", size = 396162, upload-time = "2025-11-16T14:49:31.833Z" }, + { url = "https://files.pythonhosted.org/packages/70/0d/491c1017d14f62ce7bac07c32768d209a50ec567d76d9f383b4cfad19b80/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcae1770b401167f8b9e1e3f566562e6966ffa9ce63639916248a9e25fa8a244", size = 517719, upload-time = "2025-11-16T14:49:33.804Z" }, + { url = "https://files.pythonhosted.org/packages/d7/25/b11132afcb17cd5d82db173f0c8dab270ffdfaba43e5ce7a591837ae9649/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:90f30d15f45048448b8da21c41703b31c61119c06c216a1bf8c245812a0f0c17", size = 409498, upload-time = "2025-11-16T14:49:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7d/e6543cedfb2e6403a1845710a5ab0e0ccf8fc288e0b5af9a70bfe2c12053/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44a91e0ab77bdc0004b43261a4b8cd6d6b451e8d443754cfda830002b5745b32", size = 382743, upload-time = "2025-11-16T14:49:36.704Z" }, + { url = "https://files.pythonhosted.org/packages/75/11/a4ebc9f654293ae9fefb83b2b6be7f3253e85ea42a5db2f77d50ad19aaeb/rpds_py-0.29.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:4aa195e5804d32c682e453b34474f411ca108e4291c6a0f824ebdc30a91c973c", size = 400317, upload-time = "2025-11-16T14:49:39.132Z" }, + { url = "https://files.pythonhosted.org/packages/52/18/97677a60a81c7f0e5f64e51fb3f8271c5c8fcabf3a2df18e97af53d7c2bf/rpds_py-0.29.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7971bdb7bf4ee0f7e6f67fa4c7fbc6019d9850cc977d126904392d363f6f8318", size = 416979, upload-time = "2025-11-16T14:49:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/28ab391a9968f6c746b2a2db181eaa4d16afaa859fedc9c2f682d19f7e18/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8ae33ad9ce580c7a47452c3b3f7d8a9095ef6208e0a0c7e4e2384f9fc5bf8212", size = 567288, upload-time = "2025-11-16T14:49:42.24Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d3/0c7afdcdb830eee94f5611b64e71354ffe6ac8df82d00c2faf2bfffd1d4e/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c661132ab2fb4eeede2ef69670fd60da5235209874d001a98f1542f31f2a8a94", size = 593157, upload-time = "2025-11-16T14:49:43.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ac/a0fcbc2feed4241cf26d32268c195eb88ddd4bd862adfc9d4b25edfba535/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb78b3a0d31ac1bde132c67015a809948db751cb4e92cdb3f0b242e430b6ed0d", size = 554741, upload-time = "2025-11-16T14:49:45.557Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f1/fcc24137c470df8588674a677f33719d5800ec053aaacd1de8a5d5d84d9e/rpds_py-0.29.0-cp314-cp314-win32.whl", hash = "sha256:f475f103488312e9bd4000bc890a95955a07b2d0b6e8884aef4be56132adbbf1", size = 215508, upload-time = "2025-11-16T14:49:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c7/1d169b2045512eac019918fc1021ea07c30e84a4343f9f344e3e0aa8c788/rpds_py-0.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:b9cf2359a4fca87cfb6801fae83a76aedf66ee1254a7a151f1341632acf67f1b", size = 228125, upload-time = "2025-11-16T14:49:49.064Z" }, + { url = "https://files.pythonhosted.org/packages/be/36/0cec88aaba70ec4a6e381c444b0d916738497d27f0c30406e3d9fcbd3bc2/rpds_py-0.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:9ba8028597e824854f0f1733d8b964e914ae3003b22a10c2c664cb6927e0feb9", size = 221992, upload-time = "2025-11-16T14:49:50.777Z" }, + { url = "https://files.pythonhosted.org/packages/b1/fa/a2e524631717c9c0eb5d90d30f648cfba6b731047821c994acacb618406c/rpds_py-0.29.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e71136fd0612556b35c575dc2726ae04a1669e6a6c378f2240312cf5d1a2ab10", size = 366425, upload-time = "2025-11-16T14:49:52.691Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/6d43ebe0746ff694a30233f63f454aed1677bd50ab7a59ff6b2bb5ac61f2/rpds_py-0.29.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:76fe96632d53f3bf0ea31ede2f53bbe3540cc2736d4aec3b3801b0458499ef3a", size = 355282, upload-time = "2025-11-16T14:49:54.292Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a7/52fd8270e0320b09eaf295766ae81dd175f65394687906709b3e75c71d06/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9459a33f077130dbb2c7c3cea72ee9932271fb3126404ba2a2661e4fe9eb7b79", size = 384968, upload-time = "2025-11-16T14:49:55.857Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/e6bc526b7a14e1ef80579a52c1d4ad39260a058a51d66c6039035d14db9d/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5c9546cfdd5d45e562cc0444b6dddc191e625c62e866bf567a2c69487c7ad28a", size = 394714, upload-time = "2025-11-16T14:49:57.343Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3f/f0ade3954e7db95c791e7eaf978aa7e08a756d2046e8bdd04d08146ed188/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12597d11d97b8f7e376c88929a6e17acb980e234547c92992f9f7c058f1a7310", size = 520136, upload-time = "2025-11-16T14:49:59.162Z" }, + { url = "https://files.pythonhosted.org/packages/87/b3/07122ead1b97009715ab9d4082be6d9bd9546099b2b03fae37c3116f72be/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28de03cf48b8a9e6ec10318f2197b83946ed91e2891f651a109611be4106ac4b", size = 409250, upload-time = "2025-11-16T14:50:00.698Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/dcbee61fd1dc892aedcb1b489ba661313101aa82ec84b1a015d4c63ebfda/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd7951c964069039acc9d67a8ff1f0a7f34845ae180ca542b17dc1456b1f1808", size = 384940, upload-time = "2025-11-16T14:50:02.312Z" }, + { url = "https://files.pythonhosted.org/packages/47/11/914ecb6f3574cf9bf8b38aced4063e0f787d6e1eb30b181a7efbc6c1da9a/rpds_py-0.29.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:c07d107b7316088f1ac0177a7661ca0c6670d443f6fe72e836069025e6266761", size = 399392, upload-time = "2025-11-16T14:50:03.829Z" }, + { url = "https://files.pythonhosted.org/packages/f5/fd/2f4bd9433f58f816434bb934313584caa47dbc6f03ce5484df8ac8980561/rpds_py-0.29.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1de2345af363d25696969befc0c1688a6cb5e8b1d32b515ef84fc245c6cddba3", size = 416796, upload-time = "2025-11-16T14:50:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/79/a5/449f0281af33efa29d5c71014399d74842342ae908d8cd38260320167692/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:00e56b12d2199ca96068057e1ae7f9998ab6e99cda82431afafd32f3ec98cca9", size = 566843, upload-time = "2025-11-16T14:50:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/ab/32/0a6a1ccee2e37fcb1b7ba9afde762b77182dbb57937352a729c6cd3cf2bb/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3919a3bbecee589300ed25000b6944174e07cd20db70552159207b3f4bbb45b8", size = 593956, upload-time = "2025-11-16T14:50:09.029Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3d/eb820f95dce4306f07a495ede02fb61bef36ea201d9137d4fcd5ab94ec1e/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7fa2ccc312bbd91e43aa5e0869e46bc03278a3dddb8d58833150a18b0f0283a", size = 557288, upload-time = "2025-11-16T14:50:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f8/b8ff786f40470462a252918e0836e0db903c28e88e3eec66bc4a7856ee5d/rpds_py-0.29.0-cp314-cp314t-win32.whl", hash = "sha256:97c817863ffc397f1e6a6e9d2d89fe5408c0a9922dac0329672fb0f35c867ea5", size = 211382, upload-time = "2025-11-16T14:50:12.827Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7f/1a65ae870bc9d0576aebb0c501ea5dccf1ae2178fe2821042150ebd2e707/rpds_py-0.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2023473f444752f0f82a58dfcbee040d0a1b3d1b3c2ec40e884bd25db6d117d2", size = 225919, upload-time = "2025-11-16T14:50:14.734Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ac/b97e80bf107159e5b9ba9c91df1ab95f69e5e41b435f27bdd737f0d583ac/rpds_py-0.29.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:acd82a9e39082dc5f4492d15a6b6c8599aa21db5c35aaf7d6889aea16502c07d", size = 373963, upload-time = "2025-11-16T14:50:16.205Z" }, + { url = "https://files.pythonhosted.org/packages/40/5a/55e72962d5d29bd912f40c594e68880d3c7a52774b0f75542775f9250712/rpds_py-0.29.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:715b67eac317bf1c7657508170a3e011a1ea6ccb1c9d5f296e20ba14196be6b3", size = 364644, upload-time = "2025-11-16T14:50:18.22Z" }, + { url = "https://files.pythonhosted.org/packages/99/2a/6b6524d0191b7fc1351c3c0840baac42250515afb48ae40c7ed15499a6a2/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3b1b87a237cb2dba4db18bcfaaa44ba4cd5936b91121b62292ff21df577fc43", size = 393847, upload-time = "2025-11-16T14:50:20.012Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b8/c5692a7df577b3c0c7faed7ac01ee3c608b81750fc5d89f84529229b6873/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c3c3e8101bb06e337c88eb0c0ede3187131f19d97d43ea0e1c5407ea74c0cbf", size = 407281, upload-time = "2025-11-16T14:50:21.64Z" }, + { url = "https://files.pythonhosted.org/packages/f0/57/0546c6f84031b7ea08b76646a8e33e45607cc6bd879ff1917dc077bb881e/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b8e54d6e61f3ecd3abe032065ce83ea63417a24f437e4a3d73d2f85ce7b7cfe", size = 529213, upload-time = "2025-11-16T14:50:23.219Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c1/01dd5f444233605555bc11fe5fed6a5c18f379f02013870c176c8e630a23/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3fbd4e9aebf110473a420dea85a238b254cf8a15acb04b22a5a6b5ce8925b760", size = 413808, upload-time = "2025-11-16T14:50:25.262Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0a/60f98b06156ea2a7af849fb148e00fbcfdb540909a5174a5ed10c93745c7/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80fdf53d36e6c72819993e35d1ebeeb8e8fc688d0c6c2b391b55e335b3afba5a", size = 394600, upload-time = "2025-11-16T14:50:26.956Z" }, + { url = "https://files.pythonhosted.org/packages/37/f1/dc9312fc9bec040ece08396429f2bd9e0977924ba7a11c5ad7056428465e/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:ea7173df5d86f625f8dde6d5929629ad811ed8decda3b60ae603903839ac9ac0", size = 408634, upload-time = "2025-11-16T14:50:28.989Z" }, + { url = "https://files.pythonhosted.org/packages/ed/41/65024c9fd40c89bb7d604cf73beda4cbdbcebe92d8765345dd65855b6449/rpds_py-0.29.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:76054d540061eda273274f3d13a21a4abdde90e13eaefdc205db37c05230efce", size = 426064, upload-time = "2025-11-16T14:50:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e0/cf95478881fc88ca2fdbf56381d7df36567cccc39a05394beac72182cd62/rpds_py-0.29.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:9f84c549746a5be3bc7415830747a3a0312573afc9f95785eb35228bb17742ec", size = 575871, upload-time = "2025-11-16T14:50:33.428Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c0/df88097e64339a0218b57bd5f9ca49898e4c394db756c67fccc64add850a/rpds_py-0.29.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:0ea962671af5cb9a260489e311fa22b2e97103e3f9f0caaea6f81390af96a9ed", size = 601702, upload-time = "2025-11-16T14:50:36.051Z" }, + { url = "https://files.pythonhosted.org/packages/87/f4/09ffb3ebd0cbb9e2c7c9b84d252557ecf434cd71584ee1e32f66013824df/rpds_py-0.29.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:f7728653900035fb7b8d06e1e5900545d8088efc9d5d4545782da7df03ec803f", size = 564054, upload-time = "2025-11-16T14:50:37.733Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/f0/62b5a1a723fe183650109407fa56abb433b00aa1c0b9ba555f9c4efec2c6/ruff-0.14.6.tar.gz", hash = "sha256:6f0c742ca6a7783a736b867a263b9a7a80a45ce9bee391eeda296895f1b4e1cc", size = 5669501, upload-time = "2025-11-21T14:26:17.903Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/d2/7dd544116d107fffb24a0064d41a5d2ed1c9d6372d142f9ba108c8e39207/ruff-0.14.6-py3-none-linux_armv6l.whl", hash = "sha256:d724ac2f1c240dbd01a2ae98db5d1d9a5e1d9e96eba999d1c48e30062df578a3", size = 13326119, upload-time = "2025-11-21T14:25:24.2Z" }, + { url = "https://files.pythonhosted.org/packages/36/6a/ad66d0a3315d6327ed6b01f759d83df3c4d5f86c30462121024361137b6a/ruff-0.14.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9f7539ea257aa4d07b7ce87aed580e485c40143f2473ff2f2b75aee003186004", size = 13526007, upload-time = "2025-11-21T14:25:26.906Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9d/dae6db96df28e0a15dea8e986ee393af70fc97fd57669808728080529c37/ruff-0.14.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7f6007e55b90a2a7e93083ba48a9f23c3158c433591c33ee2e99a49b889c6332", size = 12676572, upload-time = "2025-11-21T14:25:29.826Z" }, + { url = "https://files.pythonhosted.org/packages/76/a4/f319e87759949062cfee1b26245048e92e2acce900ad3a909285f9db1859/ruff-0.14.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a8e7b9d73d8728b68f632aa8e824ef041d068d231d8dbc7808532d3629a6bef", size = 13140745, upload-time = "2025-11-21T14:25:32.788Z" }, + { url = "https://files.pythonhosted.org/packages/95/d3/248c1efc71a0a8ed4e8e10b4b2266845d7dfc7a0ab64354afe049eaa1310/ruff-0.14.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d50d45d4553a3ebcbd33e7c5e0fe6ca4aafd9a9122492de357205c2c48f00775", size = 13076486, upload-time = "2025-11-21T14:25:35.601Z" }, + { url = "https://files.pythonhosted.org/packages/a5/19/b68d4563fe50eba4b8c92aa842149bb56dd24d198389c0ed12e7faff4f7d/ruff-0.14.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:118548dd121f8a21bfa8ab2c5b80e5b4aed67ead4b7567790962554f38e598ce", size = 13727563, upload-time = "2025-11-21T14:25:38.514Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/943169436832d4b0e867235abbdb57ce3a82367b47e0280fa7b4eabb7593/ruff-0.14.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:57256efafbfefcb8748df9d1d766062f62b20150691021f8ab79e2d919f7c11f", size = 15199755, upload-time = "2025-11-21T14:25:41.516Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b9/288bb2399860a36d4bb0541cb66cce3c0f4156aaff009dc8499be0c24bf2/ruff-0.14.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff18134841e5c68f8e5df1999a64429a02d5549036b394fafbe410f886e1989d", size = 14850608, upload-time = "2025-11-21T14:25:44.428Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b1/a0d549dd4364e240f37e7d2907e97ee80587480d98c7799d2d8dc7a2f605/ruff-0.14.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c4b7ec1e66a105d5c27bd57fa93203637d66a26d10ca9809dc7fc18ec58440", size = 14118754, upload-time = "2025-11-21T14:25:47.214Z" }, + { url = "https://files.pythonhosted.org/packages/13/ac/9b9fe63716af8bdfddfacd0882bc1586f29985d3b988b3c62ddce2e202c3/ruff-0.14.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:167843a6f78680746d7e226f255d920aeed5e4ad9c03258094a2d49d3028b105", size = 13949214, upload-time = "2025-11-21T14:25:50.002Z" }, + { url = "https://files.pythonhosted.org/packages/12/27/4dad6c6a77fede9560b7df6802b1b697e97e49ceabe1f12baf3ea20862e9/ruff-0.14.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:16a33af621c9c523b1ae006b1b99b159bf5ac7e4b1f20b85b2572455018e0821", size = 14106112, upload-time = "2025-11-21T14:25:52.841Z" }, + { url = "https://files.pythonhosted.org/packages/6a/db/23e322d7177873eaedea59a7932ca5084ec5b7e20cb30f341ab594130a71/ruff-0.14.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1432ab6e1ae2dc565a7eea707d3b03a0c234ef401482a6f1621bc1f427c2ff55", size = 13035010, upload-time = "2025-11-21T14:25:55.536Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9c/20e21d4d69dbb35e6a1df7691e02f363423658a20a2afacf2a2c011800dc/ruff-0.14.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4c55cfbbe7abb61eb914bfd20683d14cdfb38a6d56c6c66efa55ec6570ee4e71", size = 13054082, upload-time = "2025-11-21T14:25:58.625Z" }, + { url = "https://files.pythonhosted.org/packages/66/25/906ee6a0464c3125c8d673c589771a974965c2be1a1e28b5c3b96cb6ef88/ruff-0.14.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:efea3c0f21901a685fff4befda6d61a1bf4cb43de16da87e8226a281d614350b", size = 13303354, upload-time = "2025-11-21T14:26:01.816Z" }, + { url = "https://files.pythonhosted.org/packages/4c/58/60577569e198d56922b7ead07b465f559002b7b11d53f40937e95067ca1c/ruff-0.14.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:344d97172576d75dc6afc0e9243376dbe1668559c72de1864439c4fc95f78185", size = 14054487, upload-time = "2025-11-21T14:26:05.058Z" }, + { url = "https://files.pythonhosted.org/packages/67/0b/8e4e0639e4cc12547f41cb771b0b44ec8225b6b6a93393176d75fe6f7d40/ruff-0.14.6-py3-none-win32.whl", hash = "sha256:00169c0c8b85396516fdd9ce3446c7ca20c2a8f90a77aa945ba6b8f2bfe99e85", size = 13013361, upload-time = "2025-11-21T14:26:08.152Z" }, + { url = "https://files.pythonhosted.org/packages/fb/02/82240553b77fd1341f80ebb3eaae43ba011c7a91b4224a9f317d8e6591af/ruff-0.14.6-py3-none-win_amd64.whl", hash = "sha256:390e6480c5e3659f8a4c8d6a0373027820419ac14fa0d2713bd8e6c3e125b8b9", size = 14432087, upload-time = "2025-11-21T14:26:10.891Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1f/93f9b0fad9470e4c829a5bb678da4012f0c710d09331b860ee555216f4ea/ruff-0.14.6-py3-none-win_arm64.whl", hash = "sha256:d43c81fbeae52cfa8728d8766bbf46ee4298c888072105815b392da70ca836b2", size = 13520930, upload-time = "2025-11-21T14:26:13.951Z" }, +] + +[[package]] +name = "safetensors" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/9c/6e74567782559a63bd040a236edca26fd71bc7ba88de2ef35d75df3bca5e/safetensors-0.7.0.tar.gz", hash = "sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0", size = 200878, upload-time = "2025-11-19T15:18:43.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781, upload-time = "2025-11-19T15:18:35.84Z" }, + { url = "https://files.pythonhosted.org/packages/e8/00/374c0c068e30cd31f1e1b46b4b5738168ec79e7689ca82ee93ddfea05109/safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57", size = 447058, upload-time = "2025-11-19T15:18:34.416Z" }, + { url = "https://files.pythonhosted.org/packages/f1/06/578ffed52c2296f93d7fd2d844cabfa92be51a587c38c8afbb8ae449ca89/safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542", size = 491748, upload-time = "2025-11-19T15:18:09.79Z" }, + { url = "https://files.pythonhosted.org/packages/ae/33/1debbbb70e4791dde185edb9413d1fe01619255abb64b300157d7f15dddd/safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104", size = 503881, upload-time = "2025-11-19T15:18:16.145Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1c/40c2ca924d60792c3be509833df711b553c60effbd91da6f5284a83f7122/safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d", size = 623463, upload-time = "2025-11-19T15:18:21.11Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3a/13784a9364bd43b0d61eef4bea2845039bc2030458b16594a1bd787ae26e/safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a", size = 532855, upload-time = "2025-11-19T15:18:25.719Z" }, + { url = "https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48", size = 507152, upload-time = "2025-11-19T15:18:33.023Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a8/4b45e4e059270d17af60359713ffd83f97900d45a6afa73aaa0d737d48b6/safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981", size = 541856, upload-time = "2025-11-19T15:18:31.075Z" }, + { url = "https://files.pythonhosted.org/packages/06/87/d26d8407c44175d8ae164a95b5a62707fcc445f3c0c56108e37d98070a3d/safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b", size = 674060, upload-time = "2025-11-19T15:18:37.211Z" }, + { url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715, upload-time = "2025-11-19T15:18:38.689Z" }, + { url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377, upload-time = "2025-11-19T15:18:40.162Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368, upload-time = "2025-11-19T15:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423, upload-time = "2025-11-19T15:18:45.74Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/92/53ea2181da8ac6bf27170191028aee7251f8f841f8d3edbfdcaf2008fde9/scikit_learn-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:146b4d36f800c013d267b29168813f7a03a43ecd2895d04861f1240b564421da", size = 8595835, upload-time = "2025-12-10T07:07:39.385Z" }, + { url = "https://files.pythonhosted.org/packages/01/18/d154dc1638803adf987910cdd07097d9c526663a55666a97c124d09fb96a/scikit_learn-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f984ca4b14914e6b4094c5d52a32ea16b49832c03bd17a110f004db3c223e8e1", size = 8080381, upload-time = "2025-12-10T07:07:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/44/226142fcb7b7101e64fdee5f49dbe6288d4c7af8abf593237b70fca080a4/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e30adb87f0cc81c7690a84f7932dd66be5bac57cfe16b91cb9151683a4a2d3b", size = 8799632, upload-time = "2025-12-10T07:07:43.899Z" }, + { url = "https://files.pythonhosted.org/packages/36/4d/4a67f30778a45d542bbea5db2dbfa1e9e100bf9ba64aefe34215ba9f11f6/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ada8121bcb4dac28d930febc791a69f7cb1673c8495e5eee274190b73a4559c1", size = 9103788, upload-time = "2025-12-10T07:07:45.982Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/45c352094cfa60050bcbb967b1faf246b22e93cb459f2f907b600f2ceda5/scikit_learn-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:c57b1b610bd1f40ba43970e11ce62821c2e6569e4d74023db19c6b26f246cb3b", size = 8081706, upload-time = "2025-12-10T07:07:48.111Z" }, + { url = "https://files.pythonhosted.org/packages/3d/46/5416595bb395757f754feb20c3d776553a386b661658fb21b7c814e89efe/scikit_learn-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:2838551e011a64e3053ad7618dda9310175f7515f1742fa2d756f7c874c05961", size = 7688451, upload-time = "2025-12-10T07:07:49.873Z" }, + { url = "https://files.pythonhosted.org/packages/90/74/e6a7cc4b820e95cc38cf36cd74d5aa2b42e8ffc2d21fe5a9a9c45c1c7630/scikit_learn-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fb63362b5a7ddab88e52b6dbb47dac3fd7dafeee740dc6c8d8a446ddedade8e", size = 8548242, upload-time = "2025-12-10T07:07:51.568Z" }, + { url = "https://files.pythonhosted.org/packages/49/d8/9be608c6024d021041c7f0b3928d4749a706f4e2c3832bbede4fb4f58c95/scikit_learn-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5025ce924beccb28298246e589c691fe1b8c1c96507e6d27d12c5fadd85bfd76", size = 8079075, upload-time = "2025-12-10T07:07:53.697Z" }, + { url = "https://files.pythonhosted.org/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4496bb2cf7a43ce1a2d7524a79e40bc5da45cf598dbf9545b7e8316ccba47bb4", size = 8660492, upload-time = "2025-12-10T07:07:55.574Z" }, + { url = "https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a", size = 8931904, upload-time = "2025-12-10T07:07:57.666Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c4/0ab22726a04ede56f689476b760f98f8f46607caecff993017ac1b64aa5d/scikit_learn-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:35c007dedb2ffe38fe3ee7d201ebac4a2deccd2408e8621d53067733e3c74809", size = 8019359, upload-time = "2025-12-10T07:07:59.838Z" }, + { url = "https://files.pythonhosted.org/packages/24/90/344a67811cfd561d7335c1b96ca21455e7e472d281c3c279c4d3f2300236/scikit_learn-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:8c497fff237d7b4e07e9ef1a640887fa4fb765647f86fbe00f969ff6280ce2bb", size = 7641898, upload-time = "2025-12-10T07:08:01.36Z" }, + { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" }, + { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" }, + { url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" }, + { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" }, + { url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" }, + { url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" }, + { url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" }, + { url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/3e/9cca699f3486ce6bc12ff46dc2031f1ec8eb9ccc9a320fdaf925f1417426/scipy-1.17.0.tar.gz", hash = "sha256:2591060c8e648d8b96439e111ac41fd8342fdeff1876be2e19dea3fe8930454e", size = 30396830, upload-time = "2026-01-10T21:34:23.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/4b/c89c131aa87cad2b77a54eb0fb94d633a842420fa7e919dc2f922037c3d8/scipy-1.17.0-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:2abd71643797bd8a106dff97894ff7869eeeb0af0f7a5ce02e4227c6a2e9d6fd", size = 31381316, upload-time = "2026-01-10T21:24:33.42Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5f/a6b38f79a07d74989224d5f11b55267714707582908a5f1ae854cf9a9b84/scipy-1.17.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:ef28d815f4d2686503e5f4f00edc387ae58dfd7a2f42e348bb53359538f01558", size = 27966760, upload-time = "2026-01-10T21:24:38.911Z" }, + { url = "https://files.pythonhosted.org/packages/c1/20/095ad24e031ee8ed3c5975954d816b8e7e2abd731e04f8be573de8740885/scipy-1.17.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:272a9f16d6bb4667e8b50d25d71eddcc2158a214df1b566319298de0939d2ab7", size = 20138701, upload-time = "2026-01-10T21:24:43.249Z" }, + { url = "https://files.pythonhosted.org/packages/89/11/4aad2b3858d0337756f3323f8960755704e530b27eb2a94386c970c32cbe/scipy-1.17.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:7204fddcbec2fe6598f1c5fdf027e9f259106d05202a959a9f1aecf036adc9f6", size = 22480574, upload-time = "2026-01-10T21:24:47.266Z" }, + { url = "https://files.pythonhosted.org/packages/85/bd/f5af70c28c6da2227e510875cadf64879855193a687fb19951f0f44cfd6b/scipy-1.17.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fc02c37a5639ee67d8fb646ffded6d793c06c5622d36b35cfa8fe5ececb8f042", size = 32862414, upload-time = "2026-01-10T21:24:52.566Z" }, + { url = "https://files.pythonhosted.org/packages/ef/df/df1457c4df3826e908879fe3d76bc5b6e60aae45f4ee42539512438cfd5d/scipy-1.17.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dac97a27520d66c12a34fd90a4fe65f43766c18c0d6e1c0a80f114d2260080e4", size = 35112380, upload-time = "2026-01-10T21:24:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/5f/bb/88e2c16bd1dd4de19d80d7c5e238387182993c2fb13b4b8111e3927ad422/scipy-1.17.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ebb7446a39b3ae0fe8f416a9a3fdc6fba3f11c634f680f16a239c5187bc487c0", size = 34922676, upload-time = "2026-01-10T21:25:04.287Z" }, + { url = "https://files.pythonhosted.org/packages/02/ba/5120242cc735f71fc002cff0303d536af4405eb265f7c60742851e7ccfe9/scipy-1.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:474da16199f6af66601a01546144922ce402cb17362e07d82f5a6cf8f963e449", size = 37507599, upload-time = "2026-01-10T21:25:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/52/c8/08629657ac6c0da198487ce8cd3de78e02cfde42b7f34117d56a3fe249dc/scipy-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:255c0da161bd7b32a6c898e7891509e8a9289f0b1c6c7d96142ee0d2b114c2ea", size = 36380284, upload-time = "2026-01-10T21:25:15.632Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4a/465f96d42c6f33ad324a40049dfd63269891db9324aa66c4a1c108c6f994/scipy-1.17.0-cp311-cp311-win_arm64.whl", hash = "sha256:85b0ac3ad17fa3be50abd7e69d583d98792d7edc08367e01445a1e2076005379", size = 24370427, upload-time = "2026-01-10T21:25:20.514Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/7241a63e73ba5a516f1930ac8d5b44cbbfabd35ac73a2d08ca206df007c4/scipy-1.17.0-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:0d5018a57c24cb1dd828bcf51d7b10e65986d549f52ef5adb6b4d1ded3e32a57", size = 31364580, upload-time = "2026-01-10T21:25:25.717Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1d/5057f812d4f6adc91a20a2d6f2ebcdb517fdbc87ae3acc5633c9b97c8ba5/scipy-1.17.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:88c22af9e5d5a4f9e027e26772cc7b5922fab8bcc839edb3ae33de404feebd9e", size = 27969012, upload-time = "2026-01-10T21:25:30.921Z" }, + { url = "https://files.pythonhosted.org/packages/e3/21/f6ec556c1e3b6ec4e088da667d9987bb77cc3ab3026511f427dc8451187d/scipy-1.17.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f3cd947f20fe17013d401b64e857c6b2da83cae567adbb75b9dcba865abc66d8", size = 20140691, upload-time = "2026-01-10T21:25:34.802Z" }, + { url = "https://files.pythonhosted.org/packages/7a/fe/5e5ad04784964ba964a96f16c8d4676aa1b51357199014dce58ab7ec5670/scipy-1.17.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e8c0b331c2c1f531eb51f1b4fc9ba709521a712cce58f1aa627bc007421a5306", size = 22463015, upload-time = "2026-01-10T21:25:39.277Z" }, + { url = "https://files.pythonhosted.org/packages/4a/69/7c347e857224fcaf32a34a05183b9d8a7aca25f8f2d10b8a698b8388561a/scipy-1.17.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5194c445d0a1c7a6c1a4a4681b6b7c71baad98ff66d96b949097e7513c9d6742", size = 32724197, upload-time = "2026-01-10T21:25:44.084Z" }, + { url = "https://files.pythonhosted.org/packages/d1/fe/66d73b76d378ba8cc2fe605920c0c75092e3a65ae746e1e767d9d020a75a/scipy-1.17.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9eeb9b5f5997f75507814ed9d298ab23f62cf79f5a3ef90031b1ee2506abdb5b", size = 35009148, upload-time = "2026-01-10T21:25:50.591Z" }, + { url = "https://files.pythonhosted.org/packages/af/07/07dec27d9dc41c18d8c43c69e9e413431d20c53a0339c388bcf72f353c4b/scipy-1.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:40052543f7bbe921df4408f46003d6f01c6af109b9e2c8a66dd1cf6cf57f7d5d", size = 34798766, upload-time = "2026-01-10T21:25:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/81/61/0470810c8a093cdacd4ba7504b8a218fd49ca070d79eca23a615f5d9a0b0/scipy-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0cf46c8013fec9d3694dc572f0b54100c28405d55d3e2cb15e2895b25057996e", size = 37405953, upload-time = "2026-01-10T21:26:07.75Z" }, + { url = "https://files.pythonhosted.org/packages/92/ce/672ed546f96d5d41ae78c4b9b02006cedd0b3d6f2bf5bb76ea455c320c28/scipy-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:0937a0b0d8d593a198cededd4c439a0ea216a3f36653901ea1f3e4be949056f8", size = 36328121, upload-time = "2026-01-10T21:26:16.509Z" }, + { url = "https://files.pythonhosted.org/packages/9d/21/38165845392cae67b61843a52c6455d47d0cc2a40dd495c89f4362944654/scipy-1.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:f603d8a5518c7426414d1d8f82e253e454471de682ce5e39c29adb0df1efb86b", size = 24314368, upload-time = "2026-01-10T21:26:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/0c/51/3468fdfd49387ddefee1636f5cf6d03ce603b75205bf439bbf0e62069bfd/scipy-1.17.0-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:65ec32f3d32dfc48c72df4291345dae4f048749bc8d5203ee0a3f347f96c5ce6", size = 31344101, upload-time = "2026-01-10T21:26:30.25Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9a/9406aec58268d437636069419e6977af953d1e246df941d42d3720b7277b/scipy-1.17.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:1f9586a58039d7229ce77b52f8472c972448cded5736eaf102d5658bbac4c269", size = 27950385, upload-time = "2026-01-10T21:26:36.801Z" }, + { url = "https://files.pythonhosted.org/packages/4f/98/e7342709e17afdfd1b26b56ae499ef4939b45a23a00e471dfb5375eea205/scipy-1.17.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:9fad7d3578c877d606b1150135c2639e9de9cecd3705caa37b66862977cc3e72", size = 20122115, upload-time = "2026-01-10T21:26:42.107Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0e/9eeeb5357a64fd157cbe0302c213517c541cc16b8486d82de251f3c68ede/scipy-1.17.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:423ca1f6584fc03936972b5f7c06961670dbba9f234e71676a7c7ccf938a0d61", size = 22442402, upload-time = "2026-01-10T21:26:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c9/10/be13397a0e434f98e0c79552b2b584ae5bb1c8b2be95db421533bbca5369/scipy-1.17.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe508b5690e9eaaa9467fc047f833af58f1152ae51a0d0aed67aa5801f4dd7d6", size = 32696338, upload-time = "2026-01-10T21:26:55.521Z" }, + { url = "https://files.pythonhosted.org/packages/63/1e/12fbf2a3bb240161651c94bb5cdd0eae5d4e8cc6eaeceb74ab07b12a753d/scipy-1.17.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6680f2dfd4f6182e7d6db161344537da644d1cf85cf293f015c60a17ecf08752", size = 34977201, upload-time = "2026-01-10T21:27:03.501Z" }, + { url = "https://files.pythonhosted.org/packages/19/5b/1a63923e23ccd20bd32156d7dd708af5bbde410daa993aa2500c847ab2d2/scipy-1.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eec3842ec9ac9de5917899b277428886042a93db0b227ebbe3a333b64ec7643d", size = 34777384, upload-time = "2026-01-10T21:27:11.423Z" }, + { url = "https://files.pythonhosted.org/packages/39/22/b5da95d74edcf81e540e467202a988c50fef41bd2011f46e05f72ba07df6/scipy-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d7425fcafbc09a03731e1bc05581f5fad988e48c6a861f441b7ab729a49a55ea", size = 37379586, upload-time = "2026-01-10T21:27:20.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b6/8ac583d6da79e7b9e520579f03007cb006f063642afd6b2eeb16b890bf93/scipy-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:87b411e42b425b84777718cc41516b8a7e0795abfa8e8e1d573bf0ef014f0812", size = 36287211, upload-time = "2026-01-10T21:28:43.122Z" }, + { url = "https://files.pythonhosted.org/packages/55/fb/7db19e0b3e52f882b420417644ec81dd57eeef1bd1705b6f689d8ff93541/scipy-1.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:357ca001c6e37601066092e7c89cca2f1ce74e2a520ca78d063a6d2201101df2", size = 24312646, upload-time = "2026-01-10T21:28:49.893Z" }, + { url = "https://files.pythonhosted.org/packages/20/b6/7feaa252c21cc7aff335c6c55e1b90ab3e3306da3f048109b8b639b94648/scipy-1.17.0-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:ec0827aa4d36cb79ff1b81de898e948a51ac0b9b1c43e4a372c0508c38c0f9a3", size = 31693194, upload-time = "2026-01-10T21:27:27.454Z" }, + { url = "https://files.pythonhosted.org/packages/76/bb/bbb392005abce039fb7e672cb78ac7d158700e826b0515cab6b5b60c26fb/scipy-1.17.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:819fc26862b4b3c73a60d486dbb919202f3d6d98c87cf20c223511429f2d1a97", size = 28365415, upload-time = "2026-01-10T21:27:34.26Z" }, + { url = "https://files.pythonhosted.org/packages/37/da/9d33196ecc99fba16a409c691ed464a3a283ac454a34a13a3a57c0d66f3a/scipy-1.17.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:363ad4ae2853d88ebcde3ae6ec46ccca903ea9835ee8ba543f12f575e7b07e4e", size = 20537232, upload-time = "2026-01-10T21:27:40.306Z" }, + { url = "https://files.pythonhosted.org/packages/56/9d/f4b184f6ddb28e9a5caea36a6f98e8ecd2a524f9127354087ce780885d83/scipy-1.17.0-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:979c3a0ff8e5ba254d45d59ebd38cde48fce4f10b5125c680c7a4bfe177aab07", size = 22791051, upload-time = "2026-01-10T21:27:46.539Z" }, + { url = "https://files.pythonhosted.org/packages/9b/9d/025cccdd738a72140efc582b1641d0dd4caf2e86c3fb127568dc80444e6e/scipy-1.17.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:130d12926ae34399d157de777472bf82e9061c60cc081372b3118edacafe1d00", size = 32815098, upload-time = "2026-01-10T21:27:54.389Z" }, + { url = "https://files.pythonhosted.org/packages/48/5f/09b879619f8bca15ce392bfc1894bd9c54377e01d1b3f2f3b595a1b4d945/scipy-1.17.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e886000eb4919eae3a44f035e63f0fd8b651234117e8f6f29bad1cd26e7bc45", size = 35031342, upload-time = "2026-01-10T21:28:03.012Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9a/f0f0a9f0aa079d2f106555b984ff0fbb11a837df280f04f71f056ea9c6e4/scipy-1.17.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:13c4096ac6bc31d706018f06a49abe0485f96499deb82066b94d19b02f664209", size = 34893199, upload-time = "2026-01-10T21:28:10.832Z" }, + { url = "https://files.pythonhosted.org/packages/90/b8/4f0f5cf0c5ea4d7548424e6533e6b17d164f34a6e2fb2e43ffebb6697b06/scipy-1.17.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cacbaddd91fcffde703934897c5cd2c7cb0371fac195d383f4e1f1c5d3f3bd04", size = 37438061, upload-time = "2026-01-10T21:28:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/f9/cc/2bd59140ed3b2fa2882fb15da0a9cb1b5a6443d67cfd0d98d4cec83a57ec/scipy-1.17.0-cp313-cp313t-win_amd64.whl", hash = "sha256:edce1a1cf66298cccdc48a1bdf8fb10a3bf58e8b58d6c3883dd1530e103f87c0", size = 36328593, upload-time = "2026-01-10T21:28:28.007Z" }, + { url = "https://files.pythonhosted.org/packages/13/1b/c87cc44a0d2c7aaf0f003aef2904c3d097b422a96c7e7c07f5efd9073c1b/scipy-1.17.0-cp313-cp313t-win_arm64.whl", hash = "sha256:30509da9dbec1c2ed8f168b8d8aa853bc6723fede1dbc23c7d43a56f5ab72a67", size = 24625083, upload-time = "2026-01-10T21:28:35.188Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2d/51006cd369b8e7879e1c630999a19d1fbf6f8b5ed3e33374f29dc87e53b3/scipy-1.17.0-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:c17514d11b78be8f7e6331b983a65a7f5ca1fd037b95e27b280921fe5606286a", size = 31346803, upload-time = "2026-01-10T21:28:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2e/2349458c3ce445f53a6c93d4386b1c4c5c0c540917304c01222ff95ff317/scipy-1.17.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:4e00562e519c09da34c31685f6acc3aa384d4d50604db0f245c14e1b4488bfa2", size = 27967182, upload-time = "2026-01-10T21:29:04.107Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7c/df525fbfa77b878d1cfe625249529514dc02f4fd5f45f0f6295676a76528/scipy-1.17.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f7df7941d71314e60a481e02d5ebcb3f0185b8d799c70d03d8258f6c80f3d467", size = 20139125, upload-time = "2026-01-10T21:29:10.179Z" }, + { url = "https://files.pythonhosted.org/packages/33/11/fcf9d43a7ed1234d31765ec643b0515a85a30b58eddccc5d5a4d12b5f194/scipy-1.17.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:aabf057c632798832f071a8dde013c2e26284043934f53b00489f1773b33527e", size = 22443554, upload-time = "2026-01-10T21:29:15.888Z" }, + { url = "https://files.pythonhosted.org/packages/80/5c/ea5d239cda2dd3d31399424967a24d556cf409fbea7b5b21412b0fd0a44f/scipy-1.17.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a38c3337e00be6fd8a95b4ed66b5d988bac4ec888fd922c2ea9fe5fb1603dd67", size = 32757834, upload-time = "2026-01-10T21:29:23.406Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7e/8c917cc573310e5dc91cbeead76f1b600d3fb17cf0969db02c9cf92e3cfa/scipy-1.17.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00fb5f8ec8398ad90215008d8b6009c9db9fa924fd4c7d6be307c6f945f9cd73", size = 34995775, upload-time = "2026-01-10T21:29:31.915Z" }, + { url = "https://files.pythonhosted.org/packages/c5/43/176c0c3c07b3f7df324e7cdd933d3e2c4898ca202b090bd5ba122f9fe270/scipy-1.17.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f2a4942b0f5f7c23c7cd641a0ca1955e2ae83dedcff537e3a0259096635e186b", size = 34841240, upload-time = "2026-01-10T21:29:39.995Z" }, + { url = "https://files.pythonhosted.org/packages/44/8c/d1f5f4b491160592e7f084d997de53a8e896a3ac01cd07e59f43ca222744/scipy-1.17.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf133ced83889583156566d2bdf7a07ff89228fe0c0cb727f777de92092ec6b", size = 37394463, upload-time = "2026-01-10T21:29:48.723Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ec/42a6657f8d2d087e750e9a5dde0b481fd135657f09eaf1cf5688bb23c338/scipy-1.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:3625c631a7acd7cfd929e4e31d2582cf00f42fcf06011f59281271746d77e061", size = 37053015, upload-time = "2026-01-10T21:30:51.418Z" }, + { url = "https://files.pythonhosted.org/packages/27/58/6b89a6afd132787d89a362d443a7bddd511b8f41336a1ae47f9e4f000dc4/scipy-1.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:9244608d27eafe02b20558523ba57f15c689357c85bdcfe920b1828750aa26eb", size = 24951312, upload-time = "2026-01-10T21:30:56.771Z" }, + { url = "https://files.pythonhosted.org/packages/e9/01/f58916b9d9ae0112b86d7c3b10b9e685625ce6e8248df139d0fcb17f7397/scipy-1.17.0-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:2b531f57e09c946f56ad0b4a3b2abee778789097871fc541e267d2eca081cff1", size = 31706502, upload-time = "2026-01-10T21:29:56.326Z" }, + { url = "https://files.pythonhosted.org/packages/59/8e/2912a87f94a7d1f8b38aabc0faf74b82d3b6c9e22be991c49979f0eceed8/scipy-1.17.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:13e861634a2c480bd237deb69333ac79ea1941b94568d4b0efa5db5e263d4fd1", size = 28380854, upload-time = "2026-01-10T21:30:01.554Z" }, + { url = "https://files.pythonhosted.org/packages/bd/1c/874137a52dddab7d5d595c1887089a2125d27d0601fce8c0026a24a92a0b/scipy-1.17.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:eb2651271135154aa24f6481cbae5cc8af1f0dd46e6533fb7b56aa9727b6a232", size = 20552752, upload-time = "2026-01-10T21:30:05.93Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f0/7518d171cb735f6400f4576cf70f756d5b419a07fe1867da34e2c2c9c11b/scipy-1.17.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:c5e8647f60679790c2f5c76be17e2e9247dc6b98ad0d3b065861e082c56e078d", size = 22803972, upload-time = "2026-01-10T21:30:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/7c/74/3498563a2c619e8a3ebb4d75457486c249b19b5b04a30600dfd9af06bea5/scipy-1.17.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fb10d17e649e1446410895639f3385fd2bf4c3c7dfc9bea937bddcbc3d7b9ba", size = 32829770, upload-time = "2026-01-10T21:30:16.359Z" }, + { url = "https://files.pythonhosted.org/packages/48/d1/7b50cedd8c6c9d6f706b4b36fa8544d829c712a75e370f763b318e9638c1/scipy-1.17.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8547e7c57f932e7354a2319fab613981cde910631979f74c9b542bb167a8b9db", size = 35051093, upload-time = "2026-01-10T21:30:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/e2/82/a2d684dfddb87ba1b3ea325df7c3293496ee9accb3a19abe9429bce94755/scipy-1.17.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33af70d040e8af9d5e7a38b5ed3b772adddd281e3062ff23fec49e49681c38cf", size = 34909905, upload-time = "2026-01-10T21:30:28.704Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5e/e565bd73991d42023eb82bb99e51c5b3d9e2c588ca9d4b3e2cc1d3ca62a6/scipy-1.17.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb55bb97d00f8b7ab95cb64f873eb0bf54d9446264d9f3609130381233483f", size = 37457743, upload-time = "2026-01-10T21:30:34.819Z" }, + { url = "https://files.pythonhosted.org/packages/58/a8/a66a75c3d8f1fb2b83f66007d6455a06a6f6cf5618c3dc35bc9b69dd096e/scipy-1.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1ff269abf702f6c7e67a4b7aad981d42871a11b9dd83c58d2d2ea624efbd1088", size = 37098574, upload-time = "2026-01-10T21:30:40.782Z" }, + { url = "https://files.pythonhosted.org/packages/56/a5/df8f46ef7da168f1bc52cd86e09a9de5c6f19cc1da04454d51b7d4f43408/scipy-1.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:031121914e295d9791319a1875444d55079885bbae5bdc9c5e0f2ee5f09d34ff", size = 25246266, upload-time = "2026-01-10T21:30:45.923Z" }, +] + +[[package]] +name = "sentence-transformers" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "pillow" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "torch" }, + { name = "tqdm" }, + { name = "transformers" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/84/b30d1b29ff58cfdff423e36a50efd622c8e31d7039b1a0d5e72066620da1/sentence_transformers-4.1.0.tar.gz", hash = "sha256:f125ffd1c727533e0eca5d4567de72f84728de8f7482834de442fd90c2c3d50b", size = 272420, upload-time = "2025-04-15T13:46:13.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/2d/1151b371f28caae565ad384fdc38198f1165571870217aedda230b9d7497/sentence_transformers-4.1.0-py3-none-any.whl", hash = "sha256:382a7f6be1244a100ce40495fb7523dbe8d71b3c10b299f81e6b735092b3b8ca", size = 345695, upload-time = "2025-04-15T13:46:12.44Z" }, +] + +[[package]] +name = "setuptools" +version = "81.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, +] + +[[package]] +name = "simple-websocket" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300, upload-time = "2024-10-10T22:39:31.412Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[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 = "sse-starlette" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/3c/fa6517610dc641262b77cc7bf994ecd17465812c1b0585fe33e11be758ab/sse_starlette-3.0.3.tar.gz", hash = "sha256:88cfb08747e16200ea990c8ca876b03910a23b547ab3bd764c0d8eb81019b971", size = 21943, upload-time = "2025-10-30T18:44:20.117Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/a0/984525d19ca5c8a6c33911a0c164b11490dd0f90ff7fd689f704f84e9a11/sse_starlette-3.0.3-py3-none-any.whl", hash = "sha256:af5bf5a6f3933df1d9c7f8539633dc8444ca6a97ab2e2a7cd3b6e431ac03a431", size = 11765, upload-time = "2025-10-30T18:44:18.834Z" }, +] + +[[package]] +name = "starlette" +version = "0.50.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/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, +] + +[[package]] +name = "statsmodels" +version = "0.14.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "patsy" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/81/e8d74b34f85285f7335d30c5e3c2d7c0346997af9f3debf9a0a9a63de184/statsmodels-0.14.6.tar.gz", hash = "sha256:4d17873d3e607d398b85126cd4ed7aad89e4e9d89fc744cdab1af3189a996c2a", size = 20689085, upload-time = "2025-12-05T23:08:39.522Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/4d/df4dd089b406accfc3bb5ee53ba29bb3bdf5ae61643f86f8f604baa57656/statsmodels-0.14.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6ad5c2810fc6c684254a7792bf1cbaf1606cdee2a253f8bd259c43135d87cfb4", size = 10121514, upload-time = "2025-12-05T19:28:16.521Z" }, + { url = "https://files.pythonhosted.org/packages/82/af/ec48daa7f861f993b91a0dcc791d66e1cf56510a235c5cbd2ab991a31d5c/statsmodels-0.14.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:341fa68a7403e10a95c7b6e41134b0da3a7b835ecff1eb266294408535a06eb6", size = 10003346, upload-time = "2025-12-05T19:28:29.568Z" }, + { url = "https://files.pythonhosted.org/packages/a9/2c/c8f7aa24cd729970728f3f98822fb45149adc216f445a9301e441f7ac760/statsmodels-0.14.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdf1dfe2a3ca56f5529118baf33a13efed2783c528f4a36409b46bbd2d9d48eb", size = 10129872, upload-time = "2025-12-05T23:09:25.724Z" }, + { url = "https://files.pythonhosted.org/packages/40/c6/9ae8e9b0721e9b6eb5f340c3a0ce8cd7cce4f66e03dd81f80d60f111987f/statsmodels-0.14.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3764ba8195c9baf0925a96da0743ff218067a269f01d155ca3558deed2658ca", size = 10381964, upload-time = "2025-12-05T23:09:41.326Z" }, + { url = "https://files.pythonhosted.org/packages/28/8c/cf3d30c8c2da78e2ad1f50ade8b7fabec3ff4cdfc56fbc02e097c4577f90/statsmodels-0.14.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9e8d2e519852adb1b420e018f5ac6e6684b2b877478adf7fda2cfdb58f5acb5d", size = 10409611, upload-time = "2025-12-05T23:09:57.131Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cc/018f14ecb58c6cb89de9d52695740b7d1f5a982aa9ea312483ea3c3d5f77/statsmodels-0.14.6-cp311-cp311-win_amd64.whl", hash = "sha256:2738a00fca51196f5a7d44b06970ace6b8b30289839e4808d656f8a98e35faa7", size = 9580385, upload-time = "2025-12-05T19:28:42.778Z" }, + { url = "https://files.pythonhosted.org/packages/25/ce/308e5e5da57515dd7cab3ec37ea2d5b8ff50bef1fcc8e6d31456f9fae08e/statsmodels-0.14.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe76140ae7adc5ff0e60a3f0d56f4fffef484efa803c3efebf2fcd734d72ecb5", size = 10091932, upload-time = "2025-12-05T19:28:55.446Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/affbabf3c27fb501ec7b5808230c619d4d1a4525c07301074eb4bda92fa9/statsmodels-0.14.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:26d4f0ed3b31f3c86f83a92f5c1f5cbe63fc992cd8915daf28ca49be14463a1c", size = 9997345, upload-time = "2025-12-05T19:29:10.278Z" }, + { url = "https://files.pythonhosted.org/packages/48/f5/3a73b51e6450c31652c53a8e12e24eac64e3824be816c0c2316e7dbdcb7d/statsmodels-0.14.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8c00a42863e4f4733ac9d078bbfad816249c01451740e6f5053ecc7db6d6368", size = 10058649, upload-time = "2025-12-05T23:10:12.775Z" }, + { url = "https://files.pythonhosted.org/packages/81/68/dddd76117df2ef14c943c6bbb6618be5c9401280046f4ddfc9fb4596a1b8/statsmodels-0.14.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19b58cf7474aa9e7e3b0771a66537148b2df9b5884fbf156096c0e6c1ff0469d", size = 10339446, upload-time = "2025-12-05T23:10:28.503Z" }, + { url = "https://files.pythonhosted.org/packages/56/4a/dce451c74c4050535fac1ec0c14b80706d8fc134c9da22db3c8a0ec62c33/statsmodels-0.14.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81e7dcc5e9587f2567e52deaff5220b175bf2f648951549eae5fc9383b62bc37", size = 10368705, upload-time = "2025-12-05T23:10:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/60/15/3daba2df40be8b8a9a027d7f54c8dedf24f0d81b96e54b52293f5f7e3418/statsmodels-0.14.6-cp312-cp312-win_amd64.whl", hash = "sha256:b5eb07acd115aa6208b4058211138393a7e6c2cf12b6f213ede10f658f6a714f", size = 9543991, upload-time = "2025-12-05T23:10:58.536Z" }, + { url = "https://files.pythonhosted.org/packages/81/59/a5aad5b0cc266f5be013db8cde563ac5d2a025e7efc0c328d83b50c72992/statsmodels-0.14.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47ee7af083623d2091954fa71c7549b8443168f41b7c5dce66510274c50fd73e", size = 10072009, upload-time = "2025-12-05T23:11:14.021Z" }, + { url = "https://files.pythonhosted.org/packages/53/dd/d8cfa7922fc6dc3c56fa6c59b348ea7de829a94cd73208c6f8202dd33f17/statsmodels-0.14.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa60d82e29fcd0a736e86feb63a11d2380322d77a9369a54be8b0965a3985f71", size = 9980018, upload-time = "2025-12-05T23:11:30.907Z" }, + { url = "https://files.pythonhosted.org/packages/ee/77/0ec96803eba444efd75dba32f2ef88765ae3e8f567d276805391ec2c98c6/statsmodels-0.14.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89ee7d595f5939cc20bf946faedcb5137d975f03ae080f300ebb4398f16a5bd4", size = 10060269, upload-time = "2025-12-05T23:11:46.338Z" }, + { url = "https://files.pythonhosted.org/packages/10/b9/fd41f1f6af13a1a1212a06bb377b17762feaa6d656947bf666f76300fc05/statsmodels-0.14.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:730f3297b26749b216a06e4327fe0be59b8d05f7d594fb6caff4287b69654589", size = 10324155, upload-time = "2025-12-05T23:12:01.805Z" }, + { url = "https://files.pythonhosted.org/packages/ee/0f/a6900e220abd2c69cd0a07e3ad26c71984be6061415a60e0f17b152ecf08/statsmodels-0.14.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f1c08befa85e93acc992b72a390ddb7bd876190f1360e61d10cf43833463bc9c", size = 10349765, upload-time = "2025-12-05T23:12:18.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/08/b79f0c614f38e566eebbdcff90c0bcacf3c6ba7a5bbb12183c09c29ca400/statsmodels-0.14.6-cp313-cp313-win_amd64.whl", hash = "sha256:8021271a79f35b842c02a1794465a651a9d06ec2080f76ebc3b7adce77d08233", size = 9540043, upload-time = "2025-12-05T23:12:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/71/de/09540e870318e0c7b58316561d417be45eff731263b4234fdd2eee3511a8/statsmodels-0.14.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:00781869991f8f02ad3610da6627fd26ebe262210287beb59761982a8fa88cae", size = 10069403, upload-time = "2025-12-05T23:12:48.424Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f0/63c1bfda75dc53cee858006e1f46bd6d6f883853bea1b97949d0087766ca/statsmodels-0.14.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:73f305fbf31607b35ce919fae636ab8b80d175328ed38fdc6f354e813b86ee37", size = 9989253, upload-time = "2025-12-05T23:13:05.274Z" }, + { url = "https://files.pythonhosted.org/packages/c1/98/b0dfb4f542b2033a3341aa5f1bdd97024230a4ad3670c5b0839d54e3dcab/statsmodels-0.14.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e443e7077a6e2d3faeea72f5a92c9f12c63722686eb80bb40a0f04e4a7e267ad", size = 10090802, upload-time = "2025-12-05T23:13:20.653Z" }, + { url = "https://files.pythonhosted.org/packages/34/0e/2408735aca9e764643196212f9069912100151414dd617d39ffc72d77eee/statsmodels-0.14.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3414e40c073d725007a6603a18247ab7af3467e1af4a5e5a24e4c27bc26673b4", size = 10337587, upload-time = "2025-12-05T23:13:37.597Z" }, + { url = "https://files.pythonhosted.org/packages/0f/36/4d44f7035ab3c0b2b6a4c4ebb98dedf36246ccbc1b3e2f51ebcd7ac83abb/statsmodels-0.14.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a518d3f9889ef920116f9fa56d0338069e110f823926356946dae83bc9e33e19", size = 10363350, upload-time = "2025-12-05T23:13:53.08Z" }, + { url = "https://files.pythonhosted.org/packages/26/33/f1652d0c59fa51de18492ee2345b65372550501ad061daa38f950be390b6/statsmodels-0.14.6-cp314-cp314-win_amd64.whl", hash = "sha256:151b73e29f01fe619dbce7f66d61a356e9d1fe5e906529b78807df9189c37721", size = 9588010, upload-time = "2025-12-05T23:14:07.28Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + +[[package]] +name = "tiktoken" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/46/21ea696b21f1d6d1efec8639c204bdf20fde8bafb351e1355c72c5d7de52/tiktoken-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e227c7f96925003487c33b1b32265fad2fbcec2b7cf4817afb76d416f40f6bb", size = 1051565, upload-time = "2025-10-06T20:21:44.566Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d9/35c5d2d9e22bb2a5f74ba48266fb56c63d76ae6f66e02feb628671c0283e/tiktoken-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c06cf0fcc24c2cb2adb5e185c7082a82cba29c17575e828518c2f11a01f445aa", size = 995284, upload-time = "2025-10-06T20:21:45.622Z" }, + { url = "https://files.pythonhosted.org/packages/01/84/961106c37b8e49b9fdcf33fe007bb3a8fdcc380c528b20cc7fbba80578b8/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f18f249b041851954217e9fd8e5c00b024ab2315ffda5ed77665a05fa91f42dc", size = 1129201, upload-time = "2025-10-06T20:21:47.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d0/3d9275198e067f8b65076a68894bb52fd253875f3644f0a321a720277b8a/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:47a5bc270b8c3db00bb46ece01ef34ad050e364b51d406b6f9730b64ac28eded", size = 1152444, upload-time = "2025-10-06T20:21:48.139Z" }, + { url = "https://files.pythonhosted.org/packages/78/db/a58e09687c1698a7c592e1038e01c206569b86a0377828d51635561f8ebf/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:508fa71810c0efdcd1b898fda574889ee62852989f7c1667414736bcb2b9a4bd", size = 1195080, upload-time = "2025-10-06T20:21:49.246Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/a9e4d2bf91d515c0f74afc526fd773a812232dd6cda33ebea7f531202325/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a1af81a6c44f008cba48494089dd98cccb8b313f55e961a52f5b222d1e507967", size = 1255240, upload-time = "2025-10-06T20:21:50.274Z" }, + { url = "https://files.pythonhosted.org/packages/9d/15/963819345f1b1fb0809070a79e9dd96938d4ca41297367d471733e79c76c/tiktoken-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e68e3e593637b53e56f7237be560f7a394451cb8c11079755e80ae64b9e6def", size = 879422, upload-time = "2025-10-06T20:21:51.734Z" }, + { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" }, + { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" }, + { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" }, + { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" }, + { url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802, upload-time = "2025-10-06T20:22:00.96Z" }, + { url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995, upload-time = "2025-10-06T20:22:02.788Z" }, + { url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948, upload-time = "2025-10-06T20:22:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986, upload-time = "2025-10-06T20:22:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222, upload-time = "2025-10-06T20:22:06.265Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097, upload-time = "2025-10-06T20:22:07.403Z" }, + { url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117, upload-time = "2025-10-06T20:22:08.418Z" }, + { url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309, upload-time = "2025-10-06T20:22:10.939Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712, upload-time = "2025-10-06T20:22:12.115Z" }, + { url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725, upload-time = "2025-10-06T20:22:13.541Z" }, + { url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875, upload-time = "2025-10-06T20:22:14.559Z" }, + { url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451, upload-time = "2025-10-06T20:22:15.545Z" }, + { url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794, upload-time = "2025-10-06T20:22:16.624Z" }, + { url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777, upload-time = "2025-10-06T20:22:18.036Z" }, + { url = "https://files.pythonhosted.org/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646", size = 1050188, upload-time = "2025-10-06T20:22:19.563Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88", size = 993978, upload-time = "2025-10-06T20:22:20.702Z" }, + { url = "https://files.pythonhosted.org/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff", size = 1129271, upload-time = "2025-10-06T20:22:22.06Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830", size = 1151216, upload-time = "2025-10-06T20:22:23.085Z" }, + { url = "https://files.pythonhosted.org/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b", size = 1194860, upload-time = "2025-10-06T20:22:24.602Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b", size = 1254567, upload-time = "2025-10-06T20:22:25.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3", size = 921067, upload-time = "2025-10-06T20:22:26.753Z" }, + { url = "https://files.pythonhosted.org/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365", size = 1050473, upload-time = "2025-10-06T20:22:27.775Z" }, + { url = "https://files.pythonhosted.org/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e", size = 993855, upload-time = "2025-10-06T20:22:28.799Z" }, + { url = "https://files.pythonhosted.org/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63", size = 1129022, upload-time = "2025-10-06T20:22:29.981Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0", size = 1150736, upload-time = "2025-10-06T20:22:30.996Z" }, + { url = "https://files.pythonhosted.org/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a", size = 1194908, upload-time = "2025-10-06T20:22:32.073Z" }, + { url = "https://files.pythonhosted.org/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0", size = 1253706, upload-time = "2025-10-06T20:22:33.385Z" }, + { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" }, +] + +[[package]] +name = "tokenizers" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/46/fb6854cec3278fbfa4a75b50232c77622bc517ac886156e6afbfa4d8fc6e/tokenizers-0.22.1.tar.gz", hash = "sha256:61de6522785310a309b3407bac22d99c4db5dba349935e99e4d15ea2226af2d9", size = 363123, upload-time = "2025-09-19T09:49:23.424Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/33/f4b2d94ada7ab297328fc671fed209368ddb82f965ec2224eb1892674c3a/tokenizers-0.22.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:59fdb013df17455e5f950b4b834a7b3ee2e0271e6378ccb33aa74d178b513c73", size = 3069318, upload-time = "2025-09-19T09:49:11.848Z" }, + { url = "https://files.pythonhosted.org/packages/1c/58/2aa8c874d02b974990e89ff95826a4852a8b2a273c7d1b4411cdd45a4565/tokenizers-0.22.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:8d4e484f7b0827021ac5f9f71d4794aaef62b979ab7608593da22b1d2e3c4edc", size = 2926478, upload-time = "2025-09-19T09:49:09.759Z" }, + { url = "https://files.pythonhosted.org/packages/1e/3b/55e64befa1e7bfea963cf4b787b2cea1011362c4193f5477047532ce127e/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19d2962dd28bc67c1f205ab180578a78eef89ac60ca7ef7cbe9635a46a56422a", size = 3256994, upload-time = "2025-09-19T09:48:56.701Z" }, + { url = "https://files.pythonhosted.org/packages/71/0b/fbfecf42f67d9b7b80fde4aabb2b3110a97fac6585c9470b5bff103a80cb/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:38201f15cdb1f8a6843e6563e6e79f4abd053394992b9bbdf5213ea3469b4ae7", size = 3153141, upload-time = "2025-09-19T09:48:59.749Z" }, + { url = "https://files.pythonhosted.org/packages/17/a9/b38f4e74e0817af8f8ef925507c63c6ae8171e3c4cb2d5d4624bf58fca69/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1cbe5454c9a15df1b3443c726063d930c16f047a3cc724b9e6e1a91140e5a21", size = 3508049, upload-time = "2025-09-19T09:49:05.868Z" }, + { url = "https://files.pythonhosted.org/packages/d2/48/dd2b3dac46bb9134a88e35d72e1aa4869579eacc1a27238f1577270773ff/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e7d094ae6312d69cc2a872b54b91b309f4f6fbce871ef28eb27b52a98e4d0214", size = 3710730, upload-time = "2025-09-19T09:49:01.832Z" }, + { url = "https://files.pythonhosted.org/packages/93/0e/ccabc8d16ae4ba84a55d41345207c1e2ea88784651a5a487547d80851398/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afd7594a56656ace95cdd6df4cca2e4059d294c5cfb1679c57824b605556cb2f", size = 3412560, upload-time = "2025-09-19T09:49:03.867Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c6/dc3a0db5a6766416c32c034286d7c2d406da1f498e4de04ab1b8959edd00/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2ef6063d7a84994129732b47e7915e8710f27f99f3a3260b8a38fc7ccd083f4", size = 3250221, upload-time = "2025-09-19T09:49:07.664Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a6/2c8486eef79671601ff57b093889a345dd3d576713ef047776015dc66de7/tokenizers-0.22.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ba0a64f450b9ef412c98f6bcd2a50c6df6e2443b560024a09fa6a03189726879", size = 9345569, upload-time = "2025-09-19T09:49:14.214Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/32ce667f14c35537f5f605fe9bea3e415ea1b0a646389d2295ec348d5657/tokenizers-0.22.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:331d6d149fa9c7d632cde4490fb8bbb12337fa3a0232e77892be656464f4b446", size = 9271599, upload-time = "2025-09-19T09:49:16.639Z" }, + { url = "https://files.pythonhosted.org/packages/51/7c/a5f7898a3f6baa3fc2685c705e04c98c1094c523051c805cdd9306b8f87e/tokenizers-0.22.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:607989f2ea68a46cb1dfbaf3e3aabdf3f21d8748312dbeb6263d1b3b66c5010a", size = 9533862, upload-time = "2025-09-19T09:49:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/36/65/7e75caea90bc73c1dd8d40438adf1a7bc26af3b8d0a6705ea190462506e1/tokenizers-0.22.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a0f307d490295717726598ef6fa4f24af9d484809223bbc253b201c740a06390", size = 9681250, upload-time = "2025-09-19T09:49:21.501Z" }, + { url = "https://files.pythonhosted.org/packages/30/2c/959dddef581b46e6209da82df3b78471e96260e2bc463f89d23b1bf0e52a/tokenizers-0.22.1-cp39-abi3-win32.whl", hash = "sha256:b5120eed1442765cd90b903bb6cfef781fd8fe64e34ccaecbae4c619b7b12a82", size = 2472003, upload-time = "2025-09-19T09:49:27.089Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/e33a8c93907b631a99377ef4c5f817ab453d0b34f93529421f42ff559671/tokenizers-0.22.1-cp39-abi3-win_amd64.whl", hash = "sha256:65fd6e3fb11ca1e78a6a93602490f134d1fdeb13bcef99389d5102ea318ed138", size = 2674684, upload-time = "2025-09-19T09:49:24.953Z" }, +] + +[[package]] +name = "torch" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cublas", "cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "triton", marker = "sys_platform == 'linux'" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/0d/98b410492609e34a155fa8b121b55c7dca229f39636851c3a9ec20edea21/torch-2.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7b6a60d48062809f58595509c524b88e6ddec3ebe25833d6462eeab81e5f2ce4", size = 80529712, upload-time = "2026-03-23T18:12:02.608Z" }, + { url = "https://files.pythonhosted.org/packages/84/03/acea680005f098f79fd70c1d9d5ccc0cb4296ec2af539a0450108232fc0c/torch-2.11.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:d91aac77f24082809d2c5a93f52a5f085032740a1ebc9252a7b052ef5a4fddc6", size = 419718178, upload-time = "2026-03-23T18:10:46.675Z" }, + { url = "https://files.pythonhosted.org/packages/8c/8b/d7be22fbec9ffee6cff31a39f8750d4b3a65d349a286cf4aec74c2375662/torch-2.11.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:7aa2f9bbc6d4595ba72138026b2074be1233186150e9292865e04b7a63b8c67a", size = 530604548, upload-time = "2026-03-23T18:10:03.569Z" }, + { url = "https://files.pythonhosted.org/packages/d1/bd/9912d30b68845256aabbb4a40aeefeef3c3b20db5211ccda653544ada4b6/torch-2.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:73e24aaf8f36ab90d95cd1761208b2eb70841c2a9ca1a3f9061b39fc5331b708", size = 114519675, upload-time = "2026-03-23T18:11:52.995Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8b/69e3008d78e5cee2b30183340cc425081b78afc5eff3d080daab0adda9aa/torch-2.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b5866312ee6e52ea625cd211dcb97d6a2cdc1131a5f15cc0d87eec948f6dd34", size = 80606338, upload-time = "2026-03-23T18:11:34.781Z" }, + { url = "https://files.pythonhosted.org/packages/13/16/42e5915ebe4868caa6bac83a8ed59db57f12e9a61b7d749d584776ed53d5/torch-2.11.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f99924682ef0aa6a4ab3b1b76f40dc6e273fca09f367d15a524266db100a723f", size = 419731115, upload-time = "2026-03-23T18:11:06.944Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c9/82638ef24d7877510f83baf821f5619a61b45568ce21c0a87a91576510aa/torch-2.11.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:0f68f4ac6d95d12e896c3b7a912b5871619542ec54d3649cf48cc1edd4dd2756", size = 530712279, upload-time = "2026-03-23T18:10:31.481Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ff/6756f1c7ee302f6d202120e0f4f05b432b839908f9071157302cedfc5232/torch-2.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:fbf39280699d1b869f55eac536deceaa1b60bd6788ba74f399cc67e60a5fab10", size = 114556047, upload-time = "2026-03-23T18:10:55.931Z" }, + { url = "https://files.pythonhosted.org/packages/87/89/5ea6722763acee56b045435fb84258db7375c48165ec8be7880ab2b281c5/torch-2.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e6debd97ccd3205bbb37eb806a9d8219e1139d15419982c09e23ef7d4369d18", size = 80606801, upload-time = "2026-03-23T18:10:18.649Z" }, + { url = "https://files.pythonhosted.org/packages/32/d1/8ed2173589cbfe744ed54e5a73efc107c0085ba5777ee93a5f4c1ab90553/torch-2.11.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:63a68fa59de8f87acc7e85a5478bb2dddbb3392b7593ec3e78827c793c4b73fd", size = 419732382, upload-time = "2026-03-23T18:08:30.835Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e1/b73f7c575a4b8f87a5928f50a1e35416b5e27295d8be9397d5293e7e8d4c/torch-2.11.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:cc89b9b173d9adfab59fd227f0ab5e5516d9a52b658ae41d64e59d2e55a418db", size = 530711509, upload-time = "2026-03-23T18:08:47.213Z" }, + { url = "https://files.pythonhosted.org/packages/66/82/3e3fcdd388fbe54e29fd3f991f36846ff4ac90b0d0181e9c8f7236565f82/torch-2.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:4dda3b3f52d121063a731ddb835f010dc137b920d7fec2778e52f60d8e4bf0cd", size = 114555842, upload-time = "2026-03-23T18:09:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/db/38/8ac78069621b8c2b4979c2f96dc8409ef5e9c4189f6aac629189a78677ca/torch-2.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8b394322f49af4362d4f80e424bcaca7efcd049619af03a4cf4501520bdf0fb4", size = 80959574, upload-time = "2026-03-23T18:10:14.214Z" }, + { url = "https://files.pythonhosted.org/packages/6d/6c/56bfb37073e7136e6dd86bfc6af7339946dd684e0ecf2155ac0eee687ae1/torch-2.11.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:2658f34ce7e2dabf4ec73b45e2ca68aedad7a5be87ea756ad656eaf32bf1e1ea", size = 419732324, upload-time = "2026-03-23T18:09:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/07/f4/1b666b6d61d3394cca306ea543ed03a64aad0a201b6cd159f1d41010aeb1/torch-2.11.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:98bb213c3084cfe176302949bdc360074b18a9da7ab59ef2edc9d9f742504778", size = 530596026, upload-time = "2026-03-23T18:09:20.842Z" }, + { url = "https://files.pythonhosted.org/packages/48/6b/30d1459fa7e4b67e9e3fe1685ca1d8bb4ce7c62ef436c3a615963c6c866c/torch-2.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a97b94bbf62992949b4730c6cd2cc9aee7b335921ee8dc207d930f2ed09ae2db", size = 114793702, upload-time = "2026-03-23T18:09:47.304Z" }, + { url = "https://files.pythonhosted.org/packages/26/0d/8603382f61abd0db35841148ddc1ffd607bf3100b11c6e1dab6d2fc44e72/torch-2.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:01018087326984a33b64e04c8cb5c2795f9120e0d775ada1f6638840227b04d7", size = 80573442, upload-time = "2026-03-23T18:09:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/c7/86/7cd7c66cb9cec6be330fff36db5bd0eef386d80c031b581ec81be1d4b26c/torch-2.11.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:2bb3cc54bd0dea126b0060bb1ec9de0f9c7f7342d93d436646516b0330cd5be7", size = 419749385, upload-time = "2026-03-23T18:07:33.77Z" }, + { url = "https://files.pythonhosted.org/packages/47/e8/b98ca2d39b2e0e4730c0ee52537e488e7008025bc77ca89552ff91021f7c/torch-2.11.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4dc8b3809469b6c30b411bb8c4cad3828efd26236153d9beb6a3ec500f211a60", size = 530716756, upload-time = "2026-03-23T18:07:50.02Z" }, + { url = "https://files.pythonhosted.org/packages/78/88/d4a4cda8362f8a30d1ed428564878c3cafb0d87971fbd3947d4c84552095/torch-2.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:2b4e811728bd0cc58fb2b0948fe939a1ee2bf1422f6025be2fca4c7bd9d79718", size = 114552300, upload-time = "2026-03-23T18:09:05.617Z" }, + { url = "https://files.pythonhosted.org/packages/bf/46/4419098ed6d801750f26567b478fc185c3432e11e2cad712bc6b4c2ab0d0/torch-2.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8245477871c3700d4370352ffec94b103cfcb737229445cf9946cddb7b2ca7cd", size = 80959460, upload-time = "2026-03-23T18:09:00.818Z" }, + { url = "https://files.pythonhosted.org/packages/fd/66/54a56a4a6ceaffb567231994a9745821d3af922a854ed33b0b3a278e0a99/torch-2.11.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:ab9a8482f475f9ba20e12db84b0e55e2f58784bdca43a854a6ccd3fd4b9f75e6", size = 419735835, upload-time = "2026-03-23T18:07:18.974Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e7/0b6665f533aa9e337662dc190425abc0af1fe3234088f4454c52393ded61/torch-2.11.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:563ed3d25542d7e7bbc5b235ccfacfeb97fb470c7fee257eae599adb8005c8a2", size = 530613405, upload-time = "2026-03-23T18:08:07.014Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bf/c8d12a2c86dbfd7f40fb2f56fbf5a505ccf2d9ce131eb559dfc7c51e1a04/torch-2.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b2a43985ff5ef6ddd923bbcf99943e5f58059805787c5c9a2622bf05ca2965b0", size = 114792991, upload-time = "2026-03-23T18:08:19.216Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, +] + +[[package]] +name = "transformers" +version = "4.57.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "requests" }, + { name = "safetensors" }, + { name = "tokenizers" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/35/67252acc1b929dc88b6602e8c4a982e64f31e733b804c14bc24b47da35e6/transformers-4.57.6.tar.gz", hash = "sha256:55e44126ece9dc0a291521b7e5492b572e6ef2766338a610b9ab5afbb70689d3", size = 10134912, upload-time = "2026-01-16T10:38:39.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/b8/e484ef633af3887baeeb4b6ad12743363af7cce68ae51e938e00aaa0529d/transformers-4.57.6-py3-none-any.whl", hash = "sha256:4c9e9de11333ddfe5114bc872c9f370509198acf0b87a832a0ab9458e2bd0550", size = 11993498, upload-time = "2026-01-16T10:38:31.289Z" }, +] + +[[package]] +name = "triton" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/2c/96f92f3c60387e14cc45aed49487f3486f89ea27106c1b1376913c62abe4/triton-3.6.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49df5ef37379c0c2b5c0012286f80174fcf0e073e5ade1ca9a86c36814553651", size = 176081190, upload-time = "2026-01-20T16:16:00.523Z" }, + { url = "https://files.pythonhosted.org/packages/e0/12/b05ba554d2c623bffa59922b94b0775673de251f468a9609bc9e45de95e9/triton-3.6.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8e323d608e3a9bfcc2d9efcc90ceefb764a82b99dea12a86d643c72539ad5d3", size = 188214640, upload-time = "2026-01-20T16:00:35.869Z" }, + { url = "https://files.pythonhosted.org/packages/17/5d/08201db32823bdf77a0e2b9039540080b2e5c23a20706ddba942924ebcd6/triton-3.6.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:374f52c11a711fd062b4bfbb201fd9ac0a5febd28a96fb41b4a0f51dde3157f4", size = 176128243, upload-time = "2026-01-20T16:16:07.857Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a8/cdf8b3e4c98132f965f88c2313a4b493266832ad47fb52f23d14d4f86bb5/triton-3.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74caf5e34b66d9f3a429af689c1c7128daba1d8208df60e81106b115c00d6fca", size = 188266850, upload-time = "2026-01-20T16:00:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/3c/12/34d71b350e89a204c2c7777a9bba0dcf2f19a5bfdd70b57c4dbc5ffd7154/triton-3.6.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448e02fe6dc898e9e5aa89cf0ee5c371e99df5aa5e8ad976a80b93334f3494fd", size = 176133521, upload-time = "2026-01-20T16:16:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0b/37d991d8c130ce81a8728ae3c25b6e60935838e9be1b58791f5997b24a54/triton-3.6.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10c7f76c6e72d2ef08df639e3d0d30729112f47a56b0c81672edc05ee5116ac9", size = 188289450, upload-time = "2026-01-20T16:00:49.136Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4e/41b0c8033b503fd3cfcd12392cdd256945026a91ff02452bef40ec34bee7/triton-3.6.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1722e172d34e32abc3eb7711d0025bb69d7959ebea84e3b7f7a341cd7ed694d6", size = 176276087, upload-time = "2026-01-20T16:16:18.989Z" }, + { url = "https://files.pythonhosted.org/packages/35/f8/9c66bfc55361ec6d0e4040a0337fb5924ceb23de4648b8a81ae9d33b2b38/triton-3.6.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d002e07d7180fd65e622134fbd980c9a3d4211fb85224b56a0a0efbd422ab72f", size = 188400296, upload-time = "2026-01-20T16:00:56.042Z" }, + { url = "https://files.pythonhosted.org/packages/49/55/5ecf0dcaa0f2fbbd4420f7ef227ee3cb172e91e5fede9d0ecaddc43363b4/triton-3.6.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5523241e7d1abca00f1d240949eebdd7c673b005edbbce0aca95b8191f1d43", size = 176138577, upload-time = "2026-01-20T16:16:25.426Z" }, + { url = "https://files.pythonhosted.org/packages/df/3d/9e7eee57b37c80cec63322c0231bb6da3cfe535a91d7a4d64896fcb89357/triton-3.6.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a17a5d5985f0ac494ed8a8e54568f092f7057ef60e1b0fa09d3fd1512064e803", size = 188273063, upload-time = "2026-01-20T16:01:07.278Z" }, + { url = "https://files.pythonhosted.org/packages/48/db/56ee649cab5eaff4757541325aca81f52d02d4a7cd3506776cad2451e060/triton-3.6.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b3a97e8ed304dfa9bd23bb41ca04cdf6b2e617d5e782a8653d616037a5d537d", size = 176274804, upload-time = "2026-01-20T16:16:31.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/56/6113c23ff46c00aae423333eb58b3e60bdfe9179d542781955a5e1514cb3/triton-3.6.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46bd1c1af4b6704e554cad2eeb3b0a6513a980d470ccfa63189737340c7746a7", size = 188397994, upload-time = "2026-01-20T16:01:14.236Z" }, +] + +[[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 = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741, upload-time = "2024-10-14T23:38:35.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/a7/4cf0334105c1160dd6819f3297f8700fda7fc30ab4f61fbf3e725acbc7cc/uvloop-0.21.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c0f3fa6200b3108919f8bdabb9a7f87f20e7097ea3c543754cabc7d717d95cf8", size = 1447410, upload-time = "2024-10-14T23:37:33.612Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7c/1517b0bbc2dbe784b563d6ab54f2ef88c890fdad77232c98ed490aa07132/uvloop-0.21.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0878c2640cf341b269b7e128b1a5fed890adc4455513ca710d77d5e93aa6d6a0", size = 805476, upload-time = "2024-10-14T23:37:36.11Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ea/0bfae1aceb82a503f358d8d2fa126ca9dbdb2ba9c7866974faec1cb5875c/uvloop-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9fb766bb57b7388745d8bcc53a359b116b8a04c83a2288069809d2b3466c37e", size = 3960855, upload-time = "2024-10-14T23:37:37.683Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ca/0864176a649838b838f36d44bf31c451597ab363b60dc9e09c9630619d41/uvloop-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a375441696e2eda1c43c44ccb66e04d61ceeffcd76e4929e527b7fa401b90fb", size = 3973185, upload-time = "2024-10-14T23:37:40.226Z" }, + { url = "https://files.pythonhosted.org/packages/30/bf/08ad29979a936d63787ba47a540de2132169f140d54aa25bc8c3df3e67f4/uvloop-0.21.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:baa0e6291d91649c6ba4ed4b2f982f9fa165b5bbd50a9e203c416a2797bab3c6", size = 3820256, upload-time = "2024-10-14T23:37:42.839Z" }, + { url = "https://files.pythonhosted.org/packages/da/e2/5cf6ef37e3daf2f06e651aae5ea108ad30df3cb269102678b61ebf1fdf42/uvloop-0.21.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4509360fcc4c3bd2c70d87573ad472de40c13387f5fda8cb58350a1d7475e58d", size = 3937323, upload-time = "2024-10-14T23:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/8c/4c/03f93178830dc7ce8b4cdee1d36770d2f5ebb6f3d37d354e061eefc73545/uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c", size = 1471284, upload-time = "2024-10-14T23:37:47.833Z" }, + { url = "https://files.pythonhosted.org/packages/43/3e/92c03f4d05e50f09251bd8b2b2b584a2a7f8fe600008bcc4523337abe676/uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2", size = 821349, upload-time = "2024-10-14T23:37:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ef/a02ec5da49909dbbfb1fd205a9a1ac4e88ea92dcae885e7c961847cd51e2/uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d", size = 4580089, upload-time = "2024-10-14T23:37:51.703Z" }, + { url = "https://files.pythonhosted.org/packages/06/a7/b4e6a19925c900be9f98bec0a75e6e8f79bb53bdeb891916609ab3958967/uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc", size = 4693770, upload-time = "2024-10-14T23:37:54.122Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0c/f07435a18a4b94ce6bd0677d8319cd3de61f3a9eeb1e5f8ab4e8b5edfcb3/uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb", size = 4451321, upload-time = "2024-10-14T23:37:55.766Z" }, + { url = "https://files.pythonhosted.org/packages/8f/eb/f7032be105877bcf924709c97b1bf3b90255b4ec251f9340cef912559f28/uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f", size = 4659022, upload-time = "2024-10-14T23:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123, upload-time = "2024-10-14T23:38:00.688Z" }, + { url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325, upload-time = "2024-10-14T23:38:02.309Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806, upload-time = "2024-10-14T23:38:04.711Z" }, + { url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068, upload-time = "2024-10-14T23:38:06.385Z" }, + { url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428, upload-time = "2024-10-14T23:38:08.416Z" }, + { url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018, upload-time = "2024-10-14T23:38:10.888Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.35.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, + { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] + +[[package]] +name = "yarl" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607, upload-time = "2025-10-06T14:09:16.298Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027, upload-time = "2025-10-06T14:09:17.786Z" }, + { url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963, upload-time = "2025-10-06T14:09:19.662Z" }, + { url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406, upload-time = "2025-10-06T14:09:21.402Z" }, + { url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581, upload-time = "2025-10-06T14:09:22.98Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924, upload-time = "2025-10-06T14:09:24.655Z" }, + { url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890, upload-time = "2025-10-06T14:09:26.617Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819, upload-time = "2025-10-06T14:09:28.544Z" }, + { url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601, upload-time = "2025-10-06T14:09:30.568Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072, upload-time = "2025-10-06T14:09:32.528Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311, upload-time = "2025-10-06T14:09:34.634Z" }, + { url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094, upload-time = "2025-10-06T14:09:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944, upload-time = "2025-10-06T14:09:37.872Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804, upload-time = "2025-10-06T14:09:39.359Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858, upload-time = "2025-10-06T14:09:41.068Z" }, + { url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637, upload-time = "2025-10-06T14:09:42.712Z" }, + { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, + { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, + { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, + { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, + { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, + { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, + { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, + { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, + { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, + { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, + { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, + { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, + { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, + { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, + { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, + { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, + { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, + { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, + { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, + { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, + { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, + { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, + { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, + { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, + { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, + { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, + { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, + { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, + { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, + { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, + { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, + { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, + { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] diff --git a/labs/AgentStream/figs/evaluation_compare.png b/labs/AgentStream/figs/evaluation_compare.png new file mode 100644 index 00000000..eb09128f Binary files /dev/null and b/labs/AgentStream/figs/evaluation_compare.png differ