diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fd5b14..73833e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,7 @@ jobs: package: - { dir: submilli, module: submilli } - { dir: langchain-submilli, module: langchain_submilli } + - { dir: openai-agents-submilli, module: openai_agents_submilli } defaults: run: working-directory: packages/${{ matrix.package.dir }} @@ -48,7 +49,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - package: [submilli, langchain-submilli] + package: [submilli, langchain-submilli, openai-agents-submilli] python: ["3.11", "3.12", "3.13", "3.14"] exclude: # langchain-submilli is tested on the Pythons its dependencies support. @@ -72,6 +73,7 @@ jobs: - { package: submilli, tests: tests/integration } # Tests that need the research Blueprint's Packages are skipped here. - { package: langchain-submilli, tests: tests/integration_tests } + - { package: openai-agents-submilli, tests: tests/integration_tests } steps: - uses: actions/checkout@v7 - uses: astral-sh/setup-uv@v10.3.0 @@ -132,6 +134,9 @@ jobs: uv run --isolated --no-project --with dist/submilli-*.whl python -c "import submilli" uv run --isolated --no-project --with dist/submilli-*.whl --with dist/langchain_submilli-*.whl \ python -c "import langchain_submilli" - - name: langchain-submilli depends on submilli from PyPI, not the workspace + uv run --isolated --no-project --with dist/submilli-*.whl --with dist/openai_agents_submilli-*.whl \ + python -c "import openai_agents_submilli" + - name: The integrations depend on submilli from PyPI, not the workspace run: | unzip -p dist/langchain_submilli-*.whl '*/METADATA' | grep -x 'Requires-Dist: submilli<0.2,>=0.1.2' + unzip -p dist/openai_agents_submilli-*.whl '*/METADATA' | grep -x 'Requires-Dist: submilli<0.2,>=0.1.2' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 85463ee..6422ec9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,13 +1,13 @@ name: Release # Each package is released on its own tag: `submilli-v0.1.0` publishes the SDK, -# `langchain-submilli-v0.1.0` the integration. Publishing goes through PyPI +# `langchain-submilli-v0.1.0` and `openai-agents-submilli-v0.1.0` the integrations. Publishing goes through PyPI # trusted publishing: register this workflow and the `pypi` environment for -# both projects on PyPI first. Release `submilli` before the first -# `langchain-submilli` that needs it. +# each project on PyPI first. Release `submilli` before the first integration +# release that needs it. on: push: - tags: ["submilli-v*", "langchain-submilli-v*"] + tags: ["submilli-v*", "langchain-submilli-v*", "openai-agents-submilli-v*"] permissions: contents: read diff --git a/README.md b/README.md index 439f719..1af751a 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,13 @@ execute TypeScript code in Submilli's runtime. [Docs](https://submilli.ai/docs/) · [Connect Deep Agents](https://submilli.ai/docs/tutorials/connect-deepagents/) · [Package reference](packages/langchain-submilli#readme) · +[OpenAI Agents](packages/openai-agents-submilli#readme) · [Submilli runtime](https://github.com/submilli/submilli-runtime) · [Discord](https://discord.gg/VphpukeGGj) · [Website](https://submilli.ai) [![PyPI: langchain-submilli](https://img.shields.io/pypi/v/langchain-submilli?label=langchain-submilli)](https://pypi.org/project/langchain-submilli/) +[![PyPI: openai-agents-submilli](https://img.shields.io/pypi/v/openai-agents-submilli?label=openai-agents-submilli)](https://pypi.org/project/openai-agents-submilli/) [![PyPI: submilli](https://img.shields.io/pypi/v/submilli?label=submilli)](https://pypi.org/project/submilli/) [![CI](https://github.com/submilli/submilli-python/actions/workflows/ci.yml/badge.svg)](https://github.com/submilli/submilli-python/actions/workflows/ci.yml) [![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE) @@ -67,8 +69,11 @@ pip install "langchain-submilli[deepagents]" ``` For a different LangChain v1 agent, install `langchain-submilli` without the `deepagents` -extra. To use submilli-server directly from Python, install the -[`submilli` SDK](packages/submilli/README.md) instead. +extra. For the OpenAI Agents SDK, install +[`openai-agents-submilli`](packages/openai-agents-submilli#readme) and follow +[Connect OpenAI Agents](https://submilli.ai/docs/tutorials/connect-openai-agents/). To use +submilli-server directly from Python, install the [`submilli` SDK](packages/submilli/README.md) +instead. Follow [Connect Deep Agents](https://submilli.ai/docs/tutorials/connect-deepagents/) for the complete setup, including the server, Blueprint, model provider, agent, and session cleanup. @@ -118,16 +123,17 @@ For production, deploy with ## Status Submilli is young and moving quickly. Breaking changes are recorded in the -[`langchain-submilli` changelog](packages/langchain-submilli/CHANGELOG.md) and -[`submilli` changelog](packages/submilli/CHANGELOG.md). To talk with us, email +[`langchain-submilli`](packages/langchain-submilli/CHANGELOG.md), +[`openai-agents-submilli`](packages/openai-agents-submilli/CHANGELOG.md), and +[`submilli`](packages/submilli/CHANGELOG.md) changelogs. To talk with us, email [hello@submilli.ai](mailto:hello@submilli.ai) or join [Discord](https://discord.gg/VphpukeGGj). ## Development This repository is a [uv workspace](https://docs.astral.sh/uv/concepts/projects/workspaces/). -In the workspace, `langchain-submilli` uses `packages/submilli` directly. When published, it -depends on `submilli` from PyPI. +In the workspace, `langchain-submilli` and `openai-agents-submilli` use `packages/submilli` +directly. When published, they depend on `submilli` from PyPI. ```sh uv sync --all-packages --all-groups @@ -138,11 +144,12 @@ uv run ruff format . Run type checks and tests inside each package: ```sh -cd packages/submilli # or packages/langchain-submilli +cd packages/submilli # or packages/langchain-submilli, packages/openai-agents-submilli uv run mypy uv run pyright uv run pyright --verifytypes submilli --ignoreexternal # packages/submilli uv run pyright --verifytypes langchain_submilli --ignoreexternal # packages/langchain-submilli +uv run pyright --verifytypes openai_agents_submilli --ignoreexternal # packages/openai-agents-submilli uv run pytest ``` @@ -152,6 +159,7 @@ live in the ignored `.submilli-bin/` directory: ```sh SUBMILLI_BIN=$PWD/../../.submilli-bin uv run pytest tests/integration -m 'not durable' # submilli SUBMILLI_BIN=$PWD/../../.submilli-bin uv run pytest tests/integration_tests -m 'not durable' # langchain-submilli +SUBMILLI_BIN=$PWD/../../.submilli-bin uv run pytest tests/integration_tests # openai-agents-submilli ``` Durable integration tests require an upstream runtime build containing durable callbacks @@ -176,9 +184,10 @@ CI runs formatting, linting, type checks, unit tests, integration tests, and sta installation checks. Release a package by pushing a tag that matches its `pyproject.toml` version: -`submilli-v0.1.0` or `langchain-submilli-v0.1.0`. The release workflow publishes that package -to PyPI through trusted publishing. Release `submilli` before a `langchain-submilli` version -that depends on it. +`submilli-v0.1.0`, `langchain-submilli-v0.1.0`, or `openai-agents-submilli-v0.1.0`. The release +workflow publishes that package to PyPI through trusted publishing. Release `submilli` before an +integration version that depends on it. A new package's first release needs its PyPI project +registered for trusted publishing first. ## License diff --git a/packages/openai-agents-submilli/CHANGELOG.md b/packages/openai-agents-submilli/CHANGELOG.md new file mode 100644 index 0000000..5344843 --- /dev/null +++ b/packages/openai-agents-submilli/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +All notable changes to this project are recorded here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses +[Semantic Versioning](https://semver.org/). + +## [0.1.0] - Unreleased + +First release, for submilli-server 0.4.1, the `submilli` SDK 0.1.2, and openai-agents 0.24. + +### Added + +- `SubmilliTools` for OpenAI Agents SDK agents: the server's six tools as `FunctionTool`s, + with the Blueprint's descriptions, and one session per conversation, bound to the variables + and secrets from the run's context. +- Sessions kept in a `SessionStore`: `InMemorySessionStore` (default) or `SQLiteSessionStore`. + `aclose_conversation` ends a conversation's session. +- Retries with an `Idempotency-Key` per tool call. A session that ended is replaced, and the + model is told. A conversation whose variables changed is refused before the model runs. +- Execution and session ids, and the program's application-tool calls, in the output item's + `custom_data`. +- `ptc` (programmatic tool calling): programs call the application's function tools as + `@tools`. Each call runs as an SDK tool call, and a tool's `needs_approval` pauses the run + with a `ToolApprovalItem` for that call, resumed with `RunState.approve` or `reject`. diff --git a/packages/openai-agents-submilli/LICENSE b/packages/openai-agents-submilli/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/packages/openai-agents-submilli/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/packages/openai-agents-submilli/NOTES.md b/packages/openai-agents-submilli/NOTES.md new file mode 100644 index 0000000..d130d89 --- /dev/null +++ b/packages/openai-agents-submilli/NOTES.md @@ -0,0 +1,181 @@ +# Notes + +These are the decisions the brief (Linear SUB-2053) left open, and what got in the way. The +package mirrors `langchain-submilli` where the frameworks allow it; where they differ, this file +says why. It was built and tested against openai-agents 0.24.0, the `submilli` SDK 0.1.2 from this +workspace, and submilli-server 0.4.1 and 0.4.3 release binaries. + +## Decisions + +### The shape: a toolkit, not middleware + +The Agents SDK has no middleware. Its extension point for a code-mode harness is a list of +`FunctionTool`s on `Agent(tools=...)`, so the package is `SubmilliTools`, whose `tools` are the +server's six tools. Names, arguments, and the result shape are the same as `langchain-submilli`'s: +the server's own tool names (its responses refer to them), the MCP tools' field descriptions, +and `result`, `console`, `error`, `denied` (plus `note`, `notice`, `discovery_warnings`) as JSON +text for the model. + +- **Strict schemas.** The SDK sends function tools in strict mode by default, where every + property is required. `packages__search` therefore has a required `query`; its description + says that `""` lists everything. +- **Descriptions.** They come from `GET /v1/blueprints/{name}/prompt` before the first model + call, and are cached for the toolkit. The SDK has no hook before a model call, but it calls a + tool's `is_enabled` callable (async allowed) on every turn before it reads the tools, so the + toolkit fetches there and sets the descriptions on its tool objects. Until then each tool has a + short fallback description. It does not fetch in `__init__`, which would need a blocking + request while the agent is built. +- **What the application gets.** LangChain has message artifacts; the SDK has + `ToolCallOutputItem.custom_data`, filled by a tool's `custom_data_extractor`, which the model + never sees and `RunState` serializes. It holds `execution_id`, `session_id`, `tool_calls`, and + on refusals `refusal` or `run_status`, as the LangChain artifact does. Unlike LangChain, the + model also sees `tool_calls` without their ids, as in `@submilli/ai-sdk`: in a live run with a + real model, a program that refunded one order and was refused the next left the model unable + to say which refund had gone through. +- **Errors.** Application errors raise. Those found in `is_enabled` (a bad token, an unknown + Blueprint, a variables mismatch) come out of `Runner.run` as they are. Those found while a + tool runs (invalid variables, a missing conversation id) come out as the cause of the SDK's + `UserError("Error running tool ...")`, because the SDK wraps every exception a tool raises that + is not its own. Program failures, denials, idempotency refusals, and lost runs are tool + results. +- **Nothing to hide.** The SDK adds no file or shell tools of its own (its hosted and shell tools + are opt-in), so there is no `hide_filesystem_tools`. + +### Sessions + +- **Keyed by a conversation id from the run context.** A tool sees `ToolContext.context`, the + application's own context object, but not the SDK `Session` the run uses, nor any thread id. + So the conversation id comes from the context: `conversation_id` by default (key or + attribute), or a function. Without one, the tool raises `SubmilliConversationError`: falling + back to a session per process or per context object would silently share sessions, or leak + one per turn. +- **Stored in a `SessionStore`.** LangChain keeps the session in checkpointed graph state. The + SDK has no such state for tools, so the toolkit keeps `{id, server, blueprint, variables, + tools}` per conversation in a store: `InMemorySessionStore` by default, `SQLiteSessionStore` + (its own table, which can live in the same file as the SDK's `SQLiteSession`), or the + application's own implementation of the two-method protocol. Secrets are never stored. +- **Where the session opens.** Inside the tool, under a per-conversation lock, and it is written + to the store before any program runs. The SDK does not checkpoint between the model's turn and + the tools, so there is no earlier point that would survive a crash. A tool call resumed from a + `RunState` finds the stored session and replays its run with the same key. +- **Mismatch.** The execute tool's `is_enabled` hook compares the context's variables with the + stored session's before every model call, and raises `SubmilliSessionMismatchError` before the + model runs. The tools check again before each call. +- **Blueprint, server, or catalog change.** A stored session opened for another Blueprint, + server, or set of application tools is ignored and replaced, as in LangChain. +- **Closing.** `aclose_conversation(id)` deletes the session and forgets it. Nothing closes a + session automatically. +- **Retries.** As in LangChain: `404 unknown session` opens a replacement once per call (calls + that find the same session gone share one replacement) and tells the model; `409 + session_requires_secrets` rebinds once. + +### Idempotency + +The key is `submilli.idempotency_key(ToolContext.tool_call_id)`: the id as is when it is +printable ASCII of at most 120 bytes, otherwise its sha256. OpenAI's call ids are unique within a +conversation. The SDK's own model contract requires call ids to be stable and unique for an +invocation across resumes, which is what the key needs. + +### Application tools (`ptc`) and approvals + +This is where the design differs most from LangChain, and it is worth reading before changing +`_program.py`. + +- **The problem.** A program suspends at each `@tools` call. The application's tool should run + as the SDK would run it, with `needs_approval`, guardrails, timeouts, context injection, and + tracing, and an approval should pause the whole agent run so that a person can decide, possibly + much later and in another process. The SDK can only pause a run for an approval *before* it + invokes a tool, judged on the tool call's arguments. A program's calls happen *inside* the + execute tool, and are not known before it runs. `needs_approval` on the execute tool would + pause once per program, for the code, not once per call. +- **The answer: a nested agent whose "model" is the program.** The SDK supports agents used as + tools (`Agent.as_tool`), and pauses the outer run for approvals inside the nested run: the + outer `RunResult.interruptions` holds the nested tool's `ToolApprovalItem`, `RunState.approve` + and `reject` apply to it, and `to_string` / `from_string` serialize the nested state with the + outer. With application tools, the execute tool is built that way. Its nested agent has the + `ptc` tools, and its model is `ProgramModel`, which calls no LLM: on each turn it replays the + execute with the same idempotency key to get the run's current state, answers the calls whose + outputs the SDK recorded, and either returns a function call for the call the run waits on + next (with the server's `call_id`), or, when the run has finished, a final message holding the + execute result. The SDK runs each function call as a normal tool call, approvals included. +- **Stateless model.** Everything `ProgramModel` needs is in its input: the first message carries + the code, the session id, and the key, and the SDK's recorded function calls and outputs carry + the rest. That is what makes a resume in another process work. The session id travels in the + nested input as well as in the store, so a resumed program finds its run even if the store + changed. +- **Always through the nested agent.** With application tools, the nested agent runs the + program from its first execute on. On a resume, the SDK holds the nested run's pending result + and expects the agent tool to consume it; returning without it made the SDK raise the same + approvals again. An earlier version executed once in the outer tool first, which on a resume + with a lost store started the program again in a new session. Session replacement and secret + rebinds still happen in the outer tool, which has the run's context: the nested model lets + `404 unknown session` and `409 session_requires_secrets` propagate on its first turn, and the + outer tool retries. A `404` after the program has made calls is a lost run, reported to the + model, never a restart. +- **A resume without the store** still finishes the program in the session it began in, because + the nested input names it. The outer tool resolves the conversation's session before handing + over, so with an empty store it opens a session that this call does not use; later calls in + the conversation use it. +- **Tool outputs.** Each `ptc` tool is copied so that its exceptions are raised instead of + formatted for a model, and its output is wrapped as `{"__submilli_program_call__": "ok", + value}` or `"failed"`, `message`. Anything else the SDK records in its place, which is the + approval's rejection message, a guardrail's refusal, or a timeout message, is answered as + `denied` with that text, so the program throws `PermissionDeniedError`. The copy drops the + tool's `output_json_schema` and output adapter, which the SDK would check the envelope against + (the schema is still declared to the server). These are private dataclass fields of + `FunctionTool` (`_failure_error_function`, `_use_default_failure_error_function`, + `_output_type_adapter`), set through `dataclasses.replace`. That, and building the execute tool + from an `as_tool` template so that `RunState.from_string` finds the nested agent through the + tool's `_agent_instance`, are the package's two dependencies on SDK internals. The dependency + is pinned below the next minor release, and the tests exercise both paths. +- **Declaring.** The input schema is the tool's `params_json_schema` (what the model would see, + without the injected context). The output schema is the tool's `output_json_schema`, else + the return annotation's through a pydantic `TypeAdapter`, else `string`, where the program gets + the text the model would get. Local `$ref`s are inlined, because the server's mapper does not + resolve them. +- **The nested run's settings.** The SDK passes the outer `RunConfig` to an agent tool unless it + is given one, and `RunConfig.model` overrides every agent's model, which would send the + program's turns to an LLM. The nested run gets a `RunConfig` with the outer run's tracing + settings only. Each program call is one nested turn; `max_turns` is 10,000. +- **What the model is told.** The execute description gains the same paragraph as in LangChain, + naming the tools and, when any needs approval, saying that a refusal is final. +- **Discovery** includes `@tools` through `/v1/sessions/{id}/packages/...` when there are + application tools, which opens the conversation's session. +- **Not done: deciding at the call.** LangChain's `interrupt_on` is not mirrored. The SDK's own + setting on the tool, `needs_approval` (a bool, or an async function of the arguments), already + says which calls need approval, and it applies unchanged. + +### Packaging, tests, CI + +- Name `openai-agents-submilli`, module `openai_agents_submilli`, as `langchain-submilli` is + named. Apache-2.0, Python 3.11 or later. `openai-agents>=0.24.0,<0.25`: the SDK is below 1.0, + so a minor release may break, and the package touches the internals named above. +- Unit tests run the SDK's real `Runner` with a scripted model against the same respx fake of + the HTTP API as `langchain-submilli` (copied, not shared, so each package's tests stand alone). + They cover sessions, refusals, replays, approvals (approve, reject, two in one program, a + serialized `RunState`, and a streamed run), and `run_sync`. +- Integration tests start their own server from `SUBMILLI_BIN` or `PATH` with Blueprints + registered over the admin API, so CI runs all of them. They cover typed tools with the run's + context, the Blueprint refusing a tool, a failing tool, two approvals resumed in "another + process" (a new toolkit and agent, `RunState.from_string`, a shared `SQLiteSessionStore`), + a resume without the store, session reuse and isolation, replay, a mismatch, a restart, and an + expired session. +- Typing: `mypy --strict`, pyright, and `pyright --verifytypes` at 100%, as for the other + packages. + +### What this shares with `langchain-submilli` + +The model-facing texts (denial note, idempotency and run-state messages) and the session +policy are the same, copied rather than shared, because they are private to each package. If a +third integration needs them, they belong in the `submilli` SDK. + +## What in submilli-runtime got in the way + +Nothing new. The gaps `langchain-submilli` recorded (SUB-1685..1693) apply unchanged; two matter +more here: + +1. **A run's answered calls are not readable.** (as in langchain-submilli's notes) The nested + model rebuilds a program's progress from the SDK's recorded outputs. A server-side history of + a run's answered calls would make the records in `custom_data["tool_calls"]` authoritative. +2. **HTTP execute cannot carry the tool call id** (SUB-1686), so the run's audit record has the + idempotency key, which is the tool call id unless it was hashed. diff --git a/packages/openai-agents-submilli/README.md b/packages/openai-agents-submilli/README.md new file mode 100644 index 0000000..91525e4 --- /dev/null +++ b/packages/openai-agents-submilli/README.md @@ -0,0 +1,217 @@ +# openai-agents-submilli + +[Submilli](https://github.com/submilli/submilli-runtime) is a dedicated secure runtime for +code-mode agents, with built-in isolation, governance, and permissions. + +`openai-agents-submilli` is the Python integration for the +[OpenAI Agents SDK](https://openai.github.io/openai-agents-python/). It lets your agent execute +TypeScript code in Submilli's runtime, and lets that code call your existing function tools +under the Blueprint's policy and the SDK's own approvals. + +## Install + +Python 3.11 or later and submilli-server 0.4.1 or later are required. + +```sh +pip install openai-agents-submilli +``` + +Follow [Connect OpenAI Agents](https://submilli.ai/docs/tutorials/connect-openai-agents/) for a +complete setup with a server, Blueprint, model provider, agent, and session cleanup. + +## Use it + +```python +from agents import Agent, Runner +from openai_agents_submilli import SubmilliTools + +# Server and token come from SUBMILLI_SERVER and SUBMILLI_SERVER_TOKEN. +submilli = SubmilliTools(blueprint="research") + +# Built once and shared: the user and the conversation come with each run. +agent = Agent(name="researcher", instructions="...", tools=submilli.tools) + +result = await Runner.run( + agent, + "What is new in the latest Rust release?", + context={"user_id": "u_ada", "conversation_id": "c_1"}, +) + +# When the conversation is over: +await submilli.aclose_conversation("c_1") +``` + +## Toolkit reference + +The tools are async. `Runner.run`, `Runner.run_streamed`, and `Runner.run_sync` all work. + +```python +SubmilliTools( + blueprint="research", + server=None, + token=None, + variables=None, + secrets=None, + conversation_id=None, + store=None, + client=None, + ptc=None, +) +``` + +| Setting | Contract | +| --- | --- | +| `blueprint` | Name of a Blueprint already [registered on the server](https://submilli.ai/docs/server/register-a-blueprint/); used for every program | +| `server` | submilli-server URL; defaults to `$SUBMILLI_SERVER`, then `http://127.0.0.1:8128` | +| `token` | Server token; defaults to `$SUBMILLI_SERVER_TOKEN` | +| `variables` | Fixed `dict[str, str]`, or a function from the run context to one; defaults to mapping context `user_id` to Blueprint variable `userId` | +| `secrets` | Fixed `dict[str, str]`, or a function from the run context to one, for the Blueprint's `harness` secrets | +| `conversation_id` | Function from the run context to the conversation's id; defaults to reading context `conversation_id` | +| `store` | Where each conversation's session id is kept: `InMemorySessionStore` (default), `SQLiteSessionStore`, or your own `SessionStore` | +| `client` | Existing `submilli.AsyncSubmilli`; replaces `server` and `token` | +| `ptc` | Application tools that programs may call through the `@tools` Package | + +The run context is the `context` you pass to `Runner.run`. It may be a dict, a dataclass, or a +Pydantic model; the defaults read `user_id` and `conversation_id` as keys or attributes. The +model never sees it. + +`submilli.tools` holds the six tools below. Before the first model call, the toolkit fetches the +instructions for using them from the Submilli server. + +| Tool | Does | +| --- | --- | +| `submilli__typescript__execute` | Runs a program | +| `submilli__typescript__last_run` | Returns the conversation's last run and full console | +| `submilli__typescript__packages__search` | Searches Packages available to the program | +| `submilli__typescript__packages__docs` | Returns a Package's documentation and declarations | +| `submilli__typescript__builtins__list` | Lists built-ins available without an import | +| `submilli__typescript__builtins__docs` | Returns declarations for selected built-ins | + +## Sessions, retries, and results + +- The first program in a conversation opens a Submilli session, and later turns of the same + conversation reuse it. The session id is kept in the `store`. The default store lives in + memory; to reuse sessions after your application restarts, or from another worker, use + `SQLiteSessionStore("conversations.db")` (it can share a file with the SDK's `SQLiteSession`) + or implement `SessionStore` over your own database. Close the session with + `await submilli.aclose_conversation(conversation_id)`. +- Blueprint variables are fixed when the session opens. A later run in the same conversation + with different values raises `SubmilliSessionMismatchError` before the model is called. +- Harness secrets are sent when a session opens and are never stored. End the session before + using rotated secrets. +- Each execute call sends an `Idempotency-Key` derived from the SDK's tool call id. A repeated + call replays the recorded result. The toolkit reports conflicts and incomplete, in-progress, + or unavailable replays without silently rerunning the program. +- When a session has ended, the toolkit opens a replacement and tells the model. If a restarted + server asks for the session's secrets, it rebinds them once and retries. +- On a server with durable execution, the toolkit waits for a queued run. A run that stops + without a result, such as one that is stuck or cancelled, comes back to the model as an error + with the run's `status` and `reason`. +- The execute output the model reads contains `result`, `console`, `error`, `denied`, and the + program's application-tool calls (`tool_calls`: tool, arguments, outcome), so that after a + refusal the model can say which calls already took effect. The output item's `custom_data`, + which the model never sees, contains `execution_id`, `session_id`, and the same calls with + their `call_id`s, for linking to the audit trail. +- Errors that are the application's raise: a bad token, an unknown Blueprint, invalid variables, + a missing conversation id, an unreachable server. Errors found while a tool runs reach you as + the cause of the SDK's `UserError`. Program failures and denials are tool results. + +Submilli enforces the Blueprint only for code that runs through Submilli. Other tools you give +the agent keep their own permission boundaries. + +## Application tools + +Let programs reuse your existing function tools. A model-written program can combine calls, +loop over results, branch on conditions, and process the results in one run. + +```python +from agents import function_tool + + +@function_tool +def lookup_order(order_id: str) -> Order: ... + + +@function_tool(needs_approval=True) +def refund(order_id: str, amount: float) -> str: ... + + +submilli = SubmilliTools(blueprint="support", ptc=[lookup_order, refund]) +agent = Agent(name="support", tools=submilli.tools) +``` + +The `ptc` parameter (programmatic tool calling) tells the toolkit which tools programs can use. +Submilli makes them available to the program through the +[`@tools` Package](https://submilli.ai/docs/tutorials/connect-openai-agents/#your-tools-in-programs), +so the program can import and call them. In your Blueprint, grant [`tools.call` with a filter for +each allowed tool name](https://submilli.ai/docs/reference/http-api#application-tools). + +Each call runs as an ordinary tool call of the SDK, with the run's context, the tool's +guardrails, timeout, and tracing. Input types come from the tool's schema, and return types +come from its `output_json_schema` or return annotation. Use a `TypedDict` (from +`typing_extensions` before Python 3.12) or a Pydantic model for typed objects. A raised +exception becomes an `Error` in the TypeScript program. + +Tools in `ptc` are not given to the model directly. If you also pass them to the agent, direct +model calls bypass Submilli's Blueprint checks. + +A session keeps the application-tool catalog it opened with. Changing the catalog causes the +conversation's next call to open a new session. + +### Approvals + +A tool with `needs_approval` pauses the program at each call that needs approval, the same way +the SDK pauses at a direct tool call. The run result's `interruptions` holds a +`ToolApprovalItem` for your tool, with the program's arguments. Approve or reject it on the run +state and run again: + +```python +result = await Runner.run(agent, "Refund order o_7", context=context) +while result.interruptions: + state = result.to_state() + for item in result.interruptions: + if ask_a_person(item.tool_name, item.arguments): + state.approve(item) + else: + state.reject(item, rejection_message="Refunds over $25 need a manager.") + result = await Runner.run(agent, state) +``` + +The program waits on the server meanwhile, up to the Blueprint's `tool_call_timeout`. A +rejection throws `PermissionDeniedError` in the program with your message. Each call that needs +approval pauses on its own, so a program that refunds three orders asks three times. The state +can be saved with `to_string()` and resumed with `RunState.from_string` in another process. The +program finishes in the session it started in; share the `store` too so that later turns of the +conversation keep using that session. + +A run left waiting is not an answer. To go on without one, reject the pending calls on the state +and run it, which lets the program finish, or drop the unanswered call from the input you pass to +the next run: the Responses API refuses an input that holds a tool call without its output. A +dropped program keeps waiting on the server until `tool_call_timeout`, and its call never runs. + +## Submilli documentation + +- [Install the CLI and server](https://submilli.ai/docs/install/) +- [Create and register a Blueprint](https://submilli.ai/docs/blueprints/start-a-blueprint/) +- [Configure permissions](https://submilli.ai/docs/reference/permissions/) +- [Run the server](https://submilli.ai/docs/server/run-the-server/) +- [Inspect the audit trail](https://submilli.ai/docs/reference/audit-trail/) +- [Use the HTTP API](https://submilli.ai/docs/reference/http-api) + +## Development + +This package lives in the [submilli-python](https://github.com/submilli/submilli-python) +workspace. See the repository README for workspace setup, checks, and releases. + +```sh +uv run pytest # unit tests; the HTTP API is mocked +uv run pytest tests/integration_tests # starts a server from $SUBMILLI_BIN or PATH +``` + +The integration tests use a scripted model and need no model provider key. + +Implementation notes are in [NOTES.md](https://github.com/submilli/submilli-python/blob/main/packages/openai-agents-submilli/NOTES.md). + +## License + +Apache-2.0. diff --git a/packages/openai-agents-submilli/pyproject.toml b/packages/openai-agents-submilli/pyproject.toml new file mode 100644 index 0000000..956cd4b --- /dev/null +++ b/packages/openai-agents-submilli/pyproject.toml @@ -0,0 +1,76 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "openai-agents-submilli" +version = "0.1.0" +description = "Submilli code mode for the OpenAI Agents SDK: run an agent's tool calls as TypeScript programs on submilli-server, under a Blueprint's policy." +readme = "README.md" +license = "Apache-2.0" +license-files = ["LICENSE"] +requires-python = ">=3.11" +authors = [{ name = "Submilli" }] +keywords = ["openai", "openai-agents", "agents", "code-mode", "sandbox", "submilli"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Framework :: AsyncIO", + "Typing :: Typed", +] +# Tested with openai-agents 0.24.0; see uv.lock for the exact set. The SDK is +# below 1.0, where a minor release may break, so the bound is the next minor. +dependencies = [ + "submilli>=0.1.2,<0.2", + "openai-agents>=0.24.0,<0.25", +] + +[project.urls] +Homepage = "https://github.com/submilli/submilli-python/tree/main/packages/openai-agents-submilli" +Documentation = "https://github.com/submilli/submilli-python/tree/main/packages/openai-agents-submilli#readme" +Source = "https://github.com/submilli/submilli-python" +Changelog = "https://github.com/submilli/submilli-python/blob/main/packages/openai-agents-submilli/CHANGELOG.md" +"Submilli runtime" = "https://github.com/submilli/submilli-runtime" + +[dependency-groups] +test = [ + "pytest>=8", + "pytest-asyncio>=1", + "respx>=0.22", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/openai_agents_submilli"] + +[tool.hatch.build.targets.sdist] +include = ["src", "tests", "README.md", "NOTES.md", "CHANGELOG.md", "LICENSE"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +markers = [ + "integration: needs a submilli-server", +] +testpaths = ["tests/unit_tests"] +pythonpath = ["."] + +[tool.mypy] +strict = true +python_version = "3.11" +files = ["src", "tests"] +mypy_path = ["src"] +explicit_package_bases = true + +[tool.pyright] +include = ["src", "tests"] +pythonVersion = "3.11" +typeCheckingMode = "standard" + +[tool.uv.sources] +submilli = { workspace = true } diff --git a/packages/openai-agents-submilli/src/openai_agents_submilli/__init__.py b/packages/openai-agents-submilli/src/openai_agents_submilli/__init__.py new file mode 100644 index 0000000..e09eeb7 --- /dev/null +++ b/packages/openai-agents-submilli/src/openai_agents_submilli/__init__.py @@ -0,0 +1,31 @@ +"""Submilli code mode for the OpenAI Agents SDK. + +The HTTP API itself is the `submilli` package: its client, results and errors +are imported from there. +""" + +from openai_agents_submilli._sessions import ( + InMemorySessionStore, + SessionStore, + SQLiteSessionStore, + SubmilliSession, +) +from openai_agents_submilli._toolkit import ( + SubmilliConversationError, + SubmilliSessionMismatchError, + SubmilliTools, + context_conversation_id, + user_id_variables, +) + +__all__ = [ + "InMemorySessionStore", + "SQLiteSessionStore", + "SessionStore", + "SubmilliConversationError", + "SubmilliSession", + "SubmilliSessionMismatchError", + "SubmilliTools", + "context_conversation_id", + "user_id_variables", +] diff --git a/packages/openai-agents-submilli/src/openai_agents_submilli/_app_tools.py b/packages/openai-agents-submilli/src/openai_agents_submilli/_app_tools.py new file mode 100644 index 0000000..c9e543e --- /dev/null +++ b/packages/openai-agents-submilli/src/openai_agents_submilli/_app_tools.py @@ -0,0 +1,196 @@ +"""The application's own tools, for programs to call as the `@tools` Package. + +The toolkit declares each tool when it opens a session. A program's call +suspends its run; the call is then run as an ordinary `FunctionTool` call of a +nested agent (see `_program.py`), so the SDK's approvals, guardrails, context +and tracing apply, and the run is resumed with the result. The contract is +"Application tools" in submilli-runtime's HTTP API reference. +""" + +from __future__ import annotations + +import copy +import dataclasses +import hashlib +import json +import re +import typing +from collections.abc import Callable, Sequence +from typing import Any + +from agents import FunctionTool, function_tool +from agents.tool_context import ToolContext +from pydantic import BaseModel, TypeAdapter +from pydantic_core import to_jsonable_python + +from submilli import ToolDeclaration, ToolResult +from submilli.schema import inline_refs + +# A TypeScript function name, as the server requires of a tool's name. The +# server also drops reserved words, with a warning when the session opens. +_IDENTIFIER = re.compile(r"[A-Za-z_$][A-Za-z0-9_$]*") + +# What a tool returns when its return type gives no schema: the text the +# model would see. +_TEXT = {"type": "string"} + +# Tool outputs are wrapped in an envelope with this key, so that what the tool +# returned or raised can be told apart from text the SDK writes in its place, +# such as a rejected approval. +_ENVELOPE = "__submilli_program_call__" + + +def as_function_tools(tools: Sequence[FunctionTool | Callable[..., Any]] | None) -> list[FunctionTool]: + """The `ptc` tools as `FunctionTool`s; plain functions are wrapped with `function_tool`.""" + names = [str(item) for item in tools or [] if isinstance(item, str)] + if names: + raise TypeError(f"ptc takes tools, not tool names; pass the tool objects for: {', '.join(names)}") + converted: list[FunctionTool] = [] + for item in tools or []: + if isinstance(item, FunctionTool): + converted.append(item) + elif callable(item): + converted.append(function_tool(item)) + else: + raise TypeError(f"ptc takes FunctionTools or functions; got {type(item).__name__}") + return converted + + +def declaration(tool: FunctionTool) -> ToolDeclaration: + """`tool` as the server takes it: `{name, description, input_schema, output_schema}`. + + The input schema is the one the model would see. The output schema is the + tool's `output_json_schema`, else the one of the function's return + annotation, else a string: the tool's text. `$ref`s are inlined, because + the server's mapper does not resolve them. + """ + declared: ToolDeclaration = { + "name": tool.name, + "input_schema": inline_refs(copy.deepcopy(tool.params_json_schema)), + "output_schema": output_schema(tool), + } + if tool.description: + declared["description"] = tool.description + return declared + + +def output_schema(tool: FunctionTool) -> dict[str, Any]: + if tool.output_json_schema is not None: + return inline_refs(copy.deepcopy(tool.output_json_schema)) + try: + function = tool.__wrapped__ + except AttributeError: # not made by `function_tool` + return dict(_TEXT) + try: + annotation = typing.get_type_hints(function).get("return") + except Exception: # unresolvable annotations mean "no schema" + return dict(_TEXT) + if annotation in (None, str, Any, object, type(None)): + return dict(_TEXT) + try: + schema = TypeAdapter(annotation).json_schema(mode="serialization") + except Exception: # not a JSON type + return dict(_TEXT) + return inline_refs(schema) + + +def catalog_digest(catalog: Sequence[ToolDeclaration]) -> str | None: + """A fingerprint of the declared tools, so a conversation notices when they changed.""" + if not catalog: + return None + return hashlib.sha256(json.dumps(catalog, sort_keys=True).encode()).hexdigest() + + +class ProgramTools: + """The tools programs may call, as declared to the server and as the nested agent runs them.""" + + def __init__(self, tools: Sequence[FunctionTool]) -> None: + names = [tool.name for tool in tools] + for name in names: + if not _IDENTIFIER.fullmatch(name): + raise ValueError(f"ptc tool {name!r}: programs call tools by name, so it must be an identifier") + duplicates = sorted({name for name in names if names.count(name) > 1}) + if duplicates: + raise ValueError(f"ptc tools must have distinct names; repeated: {', '.join(duplicates)}") + self.catalog: list[ToolDeclaration] = [declaration(tool) for tool in tools] + self.digest: str | None = catalog_digest(self.catalog) + text = {item["name"]: item.get("output_schema") == _TEXT for item in self.catalog} + self.approvals: bool = any(tool.needs_approval is not False for tool in tools) + self.tools: list[FunctionTool] = [_enveloped(tool, text=text[tool.name]) for tool in tools] + self.names: frozenset[str] = frozenset(names) + + def __bool__(self) -> bool: + return bool(self.tools) + + def prompt(self) -> str: + """What the execute tool's description adds about `@tools`.""" + names = ", ".join(f"`{tool.name}`" for tool in self.tools) + approval = ( + " Some calls wait for a person's approval, who may refuse them: a refusal throws " + "`PermissionDeniedError`, and is final." + if self.approvals + else "" + ) + return ( + "\n\nThis agent's programs can also call the application's own tools, as the `@tools` package " + f'(`import tools from "@tools";`): {names}. The application runs each call and returns its ' + f"result to the program.{approval} Read their signatures with " + '`submilli__typescript__packages__docs("@tools")`.' + ) + + +def _enveloped(tool: FunctionTool, *, text: bool) -> FunctionTool: + """A copy of `tool` whose output, or failure, comes back wrapped in an envelope. + + The copy lets exceptions through to the wrapper instead of formatting them + for a model, and drops the output schema, which the SDK would check the + envelope against. Everything else, approvals included, is the tool's own. + """ + raising = dataclasses.replace( + tool, + output_json_schema=None, + _output_type_adapter=None, + _failure_error_function=None, + _use_default_failure_error_function=False, + ) + invoke = raising.on_invoke_tool + + async def on_invoke(context: ToolContext[Any], arguments: str) -> str: + try: + output = await invoke(context, arguments) + except Exception as error: # the program sees the failure + return json.dumps({_ENVELOPE: "failed", "message": f"{type(error).__name__}: {error}"}) + value = _text(output) if text else to_jsonable_python(output) + return json.dumps({_ENVELOPE: "ok", "value": value}, ensure_ascii=False) + + return dataclasses.replace(raising, on_invoke_tool=on_invoke) + + +def _text(output: Any) -> str: + if isinstance(output, str): + return output + if isinstance(output, BaseModel): + return output.model_dump_json() + try: + return json.dumps(to_jsonable_python(output), ensure_ascii=False) + except Exception: # not JSON: what the SDK would show the model + return str(output) + + +def answer(call_id: str, output: Any) -> tuple[ToolResult, str]: + """The server's answer to a call, from the output the nested agent recorded, and its outcome. + + Text in place of an envelope was written by the SDK instead of the tool: + a rejected approval, a guardrail's refusal, or a timeout. The program sees + it as a denial. + """ + text = output if isinstance(output, str) else json.dumps(output) + try: + envelope = json.loads(text) + except ValueError: + envelope = None + if isinstance(envelope, dict) and envelope.get(_ENVELOPE) == "ok": + return ToolResult.success(call_id, envelope.get("value")), "ok" + if isinstance(envelope, dict) and envelope.get(_ENVELOPE) == "failed": + return ToolResult.failed(call_id, str(envelope.get("message", ""))), "failed" + return ToolResult.denied(call_id, text), "denied" diff --git a/packages/openai-agents-submilli/src/openai_agents_submilli/_program.py b/packages/openai-agents-submilli/src/openai_agents_submilli/_program.py new file mode 100644 index 0000000..08e207d --- /dev/null +++ b/packages/openai-agents-submilli/src/openai_agents_submilli/_program.py @@ -0,0 +1,297 @@ +"""A program's calls to the application's tools, run as tool calls of a nested agent. + +A program that calls `@tools` suspends on the server at each call. Rather than +run those calls itself, the execute tool runs a nested agent whose "model" is +the program: `ProgramModel` asks the server for the run's state and answers +with a function call for the call the run waits on. The SDK then runs that +call as it runs any tool call, with the tool's `needs_approval`, guardrails, +context, hooks and tracing, and gives the output back to the model, which +resumes the run with it. When the run finishes, the model answers with the +execute tool's result. + +Approvals are therefore the SDK's own. A call that needs one interrupts the +nested run, the SDK surfaces it on the outer run as a `ToolApprovalItem` for +the application's tool, and `RunState.approve` or `reject` followed by +`Runner.run(agent, state)` carries on, as for an agent used as a tool. The +model holds no state of its own: everything it needs is in its input (the +code, the session, the idempotency key) and in the outputs the SDK recorded, +so a run resumed from a serialized `RunState` in another process works too. +""" + +from __future__ import annotations + +import json +from collections.abc import AsyncIterator +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from agents import Agent, ModelResponse, ModelSettings, RunConfig, Tool, Usage +from agents.models.interface import Model +from openai.types.responses import ResponseFunctionToolCall, ResponseOutputMessage, ResponseOutputText + +from openai_agents_submilli import _tools as t +from openai_agents_submilli._app_tools import ProgramTools, answer +from submilli import ( + AsyncSubmilli, + ExecuteResult, + IdempotencyError, + PendingCall, + RunState, + RunStateError, + ToolResult, + UnknownRunError, + UnknownSessionError, +) + +if TYPE_CHECKING: + from agents.agent_output import AgentOutputSchemaBase + from agents.handoffs import Handoff + from agents.items import TResponseInputItem, TResponseStreamEvent + from agents.models.interface import ModelTracing + from openai.types.responses.response_prompt_param import ResponsePromptParam + +PROGRAM_AGENT = "submilli_program" + +# Each call a program makes to the application's tools is one turn of the +# nested agent. +MAX_PROGRAM_CALLS = 10_000 + +# The nested model's final answer, which the execute tool unwraps. +_RESULT = "__submilli_execute_result__" + + +@dataclass(frozen=True) +class ProgramInput: + """What the nested agent is given: enough to find the run again, in any process.""" + + code: str + session_id: str + idempotency_key: str | None + notice: str | None = None + + def to_json(self) -> str: + return json.dumps( + { + "code": self.code, + "session_id": self.session_id, + "idempotency_key": self.idempotency_key, + "notice": self.notice, + }, + ensure_ascii=False, + ) + + @classmethod + def from_json(cls, text: str) -> ProgramInput: + data = json.loads(text) + return cls( + code=data["code"], + session_id=data["session_id"], + idempotency_key=data.get("idempotency_key"), + notice=data.get("notice"), + ) + + +def final_text(outcome: t.Outcome) -> str: + content, data = outcome + return json.dumps({_RESULT: {"content": content, "data": data}}, ensure_ascii=False) + + +def unwrap(output: Any) -> t.Outcome | None: + """The outcome a nested run answered with, or `None` when it stopped for an approval.""" + if not isinstance(output, str): + return None + try: + parsed = json.loads(output) + except ValueError: + return None + if not isinstance(parsed, dict) or not isinstance(parsed.get(_RESULT), dict): + return None + result = parsed[_RESULT] + return dict(result["content"]), dict(result["data"]) + + +class _NeedsCall(Exception): + """The run waits on a call that has no output yet: the nested agent must make it.""" + + def __init__(self, call: PendingCall) -> None: + super().__init__(call.call_id) + self.call = call + + +class ProgramModel(Model): + """The nested agent's model: the program's run on submilli-server.""" + + def __init__(self, client: AsyncSubmilli, tools: ProgramTools) -> None: + self.client = client + self.tools = tools + + async def get_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], + model_settings: ModelSettings, + tools: list[Tool], + output_schema: AgentOutputSchemaBase | None, + handoffs: list[Handoff[Any, Any]], + tracing: ModelTracing, + *, + previous_response_id: str | None, + conversation_id: str | None, + prompt: ResponsePromptParam | None, + ) -> ModelResponse: + program, calls, outputs = _read(input) + records = [_record(call, outputs.get(call_id)) for call_id, call in calls.items()] + session_id = program.session_id + + async def on_call(call: PendingCall) -> ToolResult: + if call.tool not in self.tools.names: + return ToolResult.failed(call.call_id, f"the application has no tool named {call.tool!r}") + if call.call_id not in outputs: + raise _NeedsCall(call) + result, _ = answer(call.call_id, outputs[call.call_id]) + return result + + try: + # With the same key, this answers the run's current state. + state: RunState = await self.client.sessions.execute( + session_id, program.code, idempotency_key=program.idempotency_key + ) + result = await self.client.runs.complete(state, on_tool_call=on_call) + except _NeedsCall as needs: + return _response(_function_call(needs.call)) + except IdempotencyError as error: + return _response(_message(final_text(t.idempotency_outcome(error, session_id=session_id)))) + except RunStateError as error: + outcome = t.run_state_outcome(error, session_id=session_id, tool_calls=records) + return _response(_message(final_text(outcome))) + except UnknownSessionError: + if not calls: + # Nothing ran yet: the execute tool opens a new session and starts again. + raise + outcome = t.lost_run_outcome(execution_id="", session_id=session_id, tool_calls=records) + return _response(_message(final_text(outcome))) + except UnknownRunError as error: + outcome = t.lost_run_outcome( + execution_id=error.execution_id or "", session_id=session_id, tool_calls=records + ) + return _response(_message(final_text(outcome))) + if not isinstance(result, ExecuteResult): # pragma: no cover - `complete` returns only finished runs + raise TypeError(f"unexpected run state {result!r}") + outcome = t.execute_outcome(result, notice=program.notice, tool_calls=records) + return _response(_message(final_text(outcome))) + + def stream_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], + model_settings: ModelSettings, + tools: list[Tool], + output_schema: AgentOutputSchemaBase | None, + handoffs: list[Handoff[Any, Any]], + tracing: ModelTracing, + *, + previous_response_id: str | None, + conversation_id: str | None, + prompt: ResponsePromptParam | None, + ) -> AsyncIterator[TResponseStreamEvent]: + raise NotImplementedError("the program's nested agent is never streamed") + + +def program_agent(client: AsyncSubmilli, tools: ProgramTools) -> Agent[Any]: + """The nested agent that runs a program's calls to the application's tools.""" + return Agent(name=PROGRAM_AGENT, model=ProgramModel(client, tools), tools=list(tools.tools)) + + +def nested_run_config(parent: RunConfig | None) -> RunConfig: + """The nested run's settings: the parent's tracing, and nothing that would swap the model. + + A parent `RunConfig.model` overrides every agent's model, the nested one's + included, and run-level guardrails are meant for the model's turns, so + neither is passed on. + """ + if parent is None: + return RunConfig() + return RunConfig( + tracing_disabled=parent.tracing_disabled, + tracing=parent.tracing, + trace_include_sensitive_data=parent.trace_include_sensitive_data, + workflow_name=parent.workflow_name, + group_id=parent.group_id, + trace_metadata=parent.trace_metadata, + tool_execution=parent.tool_execution, + ) + + +# Reading the nested agent's input + + +def _read( + input: str | list[TResponseInputItem], +) -> tuple[ProgramInput, dict[str, ResponseFunctionToolCall], dict[str, Any]]: + """The program, the calls made so far by call id, and their outputs by call id.""" + items: list[Any] = [{"role": "user", "content": input}] if isinstance(input, str) else list(input) + program: ProgramInput | None = None + calls: dict[str, ResponseFunctionToolCall] = {} + outputs: dict[str, Any] = {} + for item in items: + kind = _field(item, "type") + if program is None and _field(item, "role") == "user": + program = ProgramInput.from_json(_content_text(_field(item, "content"))) + elif kind == "function_call": + call = ResponseFunctionToolCall( + type="function_call", + call_id=str(_field(item, "call_id")), + name=str(_field(item, "name")), + arguments=str(_field(item, "arguments") or "{}"), + ) + calls[call.call_id] = call + elif kind == "function_call_output": + outputs[str(_field(item, "call_id"))] = _field(item, "output") + if program is None: + raise ValueError("the program's nested agent was started without its program") + return program, calls, outputs + + +def _field(item: Any, name: str) -> Any: + return item.get(name) if isinstance(item, dict) else getattr(item, name, None) + + +def _content_text(content: Any) -> str: + if isinstance(content, str): + return content + return "".join(str(_field(part, "text") or "") for part in content or []) + + +def _record(call: ResponseFunctionToolCall, output: Any) -> dict[str, Any]: + """A program's call as the application sees it in `custom_data["tool_calls"]`.""" + try: + args = json.loads(call.arguments) + except ValueError: + args = call.arguments + record: dict[str, Any] = {"call_id": call.call_id, "tool": call.name, "args": args} + if output is not None: + _, record["outcome"] = answer(call.call_id, output) + return record + + +def _function_call(call: PendingCall) -> ResponseFunctionToolCall: + return ResponseFunctionToolCall( + type="function_call", + call_id=call.call_id, + name=call.tool, + arguments=json.dumps(call.args, ensure_ascii=False), + ) + + +def _message(text: str) -> ResponseOutputMessage: + return ResponseOutputMessage( + id="submilli-program", + type="message", + role="assistant", + status="completed", + content=[ResponseOutputText(type="output_text", text=text, annotations=[])], + ) + + +def _response(item: ResponseFunctionToolCall | ResponseOutputMessage) -> ModelResponse: + return ModelResponse(output=[item], usage=Usage(), response_id=None) diff --git a/packages/openai-agents-submilli/src/openai_agents_submilli/_sessions.py b/packages/openai-agents-submilli/src/openai_agents_submilli/_sessions.py new file mode 100644 index 0000000..ae32a71 --- /dev/null +++ b/packages/openai-agents-submilli/src/openai_agents_submilli/_sessions.py @@ -0,0 +1,114 @@ +"""Where each conversation's Submilli session is kept between turns.""" + +from __future__ import annotations + +import asyncio +import json +import sqlite3 +import threading +from pathlib import Path +from typing import NotRequired, Protocol, TypedDict, runtime_checkable + + +class SubmilliSession(TypedDict): + """The session a conversation runs its programs in.""" + + id: str + server: str + blueprint: str + variables: dict[str, str] + #: A fingerprint of the application tools the session was opened with. + tools: NotRequired[str | None] + + +@runtime_checkable +class SessionStore(Protocol): + """Keeps the Submilli session of each conversation, by conversation id. + + Implement it over the application's own storage to keep sessions across + restarts and processes. `InMemorySessionStore` and `SQLiteSessionStore` + are provided. + """ + + async def get(self, conversation_id: str) -> SubmilliSession | None: + """The conversation's session, or `None`.""" + ... + + async def set(self, conversation_id: str, session: SubmilliSession | None) -> None: + """Record the conversation's session; `None` forgets it.""" + ... + + +class InMemorySessionStore: + """Sessions in this process's memory. They are forgotten when it exits.""" + + def __init__(self) -> None: + self._sessions: dict[str, SubmilliSession] = {} + + async def get(self, conversation_id: str) -> SubmilliSession | None: + """The conversation's session, or `None`.""" + return self._sessions.get(conversation_id) + + async def set(self, conversation_id: str, session: SubmilliSession | None) -> None: + """Record the conversation's session; `None` forgets it.""" + if session is None: + self._sessions.pop(conversation_id, None) + else: + self._sessions[conversation_id] = session + + +class SQLiteSessionStore: + """Sessions in a SQLite database, in their own table. + + It can share a file with the Agents SDK's `SQLiteSession`, so that a + conversation's history and its Submilli session are kept together. + + Args: + db_path: The database file. `":memory:"` keeps it in memory. + table: The table's name. + """ + + def __init__(self, db_path: str | Path = ":memory:", *, table: str = "submilli_sessions") -> None: + if not table.isidentifier(): + raise ValueError(f"table must be an identifier; got {table!r}") + self.db_path: str | Path = db_path + self.table: str = table + self._lock = threading.Lock() + self._connection = sqlite3.connect(str(db_path), check_same_thread=False) + with self._lock, self._connection: + self._connection.execute( + f"CREATE TABLE IF NOT EXISTS {table} (conversation_id TEXT PRIMARY KEY, session TEXT NOT NULL)" + ) + + async def get(self, conversation_id: str) -> SubmilliSession | None: + """The conversation's session, or `None`.""" + return await asyncio.to_thread(self._get, conversation_id) + + async def set(self, conversation_id: str, session: SubmilliSession | None) -> None: + """Record the conversation's session; `None` forgets it.""" + await asyncio.to_thread(self._set, conversation_id, session) + + def close(self) -> None: + """Close the database connection.""" + with self._lock: + self._connection.close() + + def _get(self, conversation_id: str) -> SubmilliSession | None: + with self._lock: + row = self._connection.execute( + f"SELECT session FROM {self.table} WHERE conversation_id = ?", (conversation_id,) + ).fetchone() + if row is None: + return None + session: SubmilliSession = json.loads(row[0]) + return session + + def _set(self, conversation_id: str, session: SubmilliSession | None) -> None: + with self._lock, self._connection: + if session is None: + self._connection.execute(f"DELETE FROM {self.table} WHERE conversation_id = ?", (conversation_id,)) + else: + self._connection.execute( + f"INSERT OR REPLACE INTO {self.table} (conversation_id, session) VALUES (?, ?)", + (conversation_id, json.dumps(session)), + ) diff --git a/packages/openai-agents-submilli/src/openai_agents_submilli/_toolkit.py b/packages/openai-agents-submilli/src/openai_agents_submilli/_toolkit.py new file mode 100644 index 0000000..b578a54 --- /dev/null +++ b/packages/openai-agents-submilli/src/openai_agents_submilli/_toolkit.py @@ -0,0 +1,496 @@ +"""`SubmilliTools`: Submilli code mode for OpenAI Agents SDK agents.""" + +from __future__ import annotations + +import asyncio +import dataclasses +import json +import logging +from collections import OrderedDict +from collections.abc import Awaitable, Callable, Mapping, Sequence +from typing import Any + +from agents import FunctionTool, RunContextWrapper +from agents.tool import FunctionToolCustomDataContext +from agents.tool_context import ToolContext + +from openai_agents_submilli import _tools as t +from openai_agents_submilli._app_tools import ProgramTools, as_function_tools +from openai_agents_submilli._program import ( + MAX_PROGRAM_CALLS, + ProgramInput, + nested_run_config, + program_agent, + unwrap, +) +from openai_agents_submilli._sessions import InMemorySessionStore, SessionStore, SubmilliSession +from submilli import ( + AsyncSubmilli, + ExecuteResult, + IdempotencyError, + RunStateError, + SessionRequiresSecretsError, + SubmilliError, + UnknownSessionError, + idempotency_key, +) + +logger = logging.getLogger(__name__) + +ContextMapper = Callable[[Any], Mapping[str, str]] +ConversationMapper = Callable[[Any], "str | None"] + +# Outcomes kept for `custom_data` until the SDK asks for them, by tool call id. +_MAX_PENDING_DATA = 1024 + + +class SubmilliSessionMismatchError(SubmilliError): + """The run's context binds different variables than the conversation's session was opened with.""" + + +class SubmilliConversationError(SubmilliError): + """The run's context names no conversation, so there is no session to run programs in.""" + + +def user_id_variables(context: Any) -> dict[str, str]: + """The default `variables`: `user_id` in the run context becomes `userId`. + + Reads a mapping key or an attribute, so a dict, a dataclass, and a Pydantic + model all work. A context without `user_id` binds no variables. + """ + user_id = _context_value(context, "user_id") + return {} if user_id is None else {"userId": str(user_id)} + + +def context_conversation_id(context: Any) -> str | None: + """The default `conversation_id`: `conversation_id` in the run context.""" + value = _context_value(context, "conversation_id") + return None if value is None else str(value) + + +def no_secrets(_: Any) -> dict[str, str]: + """The default `secrets`: none.""" + return {} + + +class SubmilliTools: + """The server's six tools for an OpenAI Agents SDK agent, with one Submilli session per conversation. + + Every program runs under the Blueprint's policy, in the conversation's + session, bound to the variables the run's context gives. Build it and the + agent once and share them; the user and the conversation come with each + run, in its context. + + Args: + blueprint: The registered Blueprint every program runs under. + server: Base URL of submilli-server. Defaults to `SUBMILLI_SERVER`, + then `http://127.0.0.1:8128`. + token: API token. Defaults to `SUBMILLI_SERVER_TOKEN`. + variables: Values for the Blueprint's variables, from the run's + context (`RunContextWrapper.context`): a callable taking the + context and returning `dict[str, str]`, or a fixed mapping. The + default maps `user_id` in the context to `userId`. + secrets: Values for the Blueprint's `harness` secrets, the same way. + Sent when a session opens and when the server asks for them again + after a restart; never stored. Default: none. + conversation_id: The conversation a run belongs to, from its context. + Programs of one conversation share a session. The default reads + `conversation_id` from the context. + store: Where each conversation's session is kept between turns. + Defaults to an `InMemorySessionStore`; use `SQLiteSessionStore`, or + your own `SessionStore`, to keep sessions across restarts. + client: A `submilli.AsyncSubmilli` to use instead of building one + from `server` and `token`. + ptc: Programmatic tool calling: the application's own tools, which + programs may call as the `@tools` Package. `FunctionTool`s, or + functions to wrap with `function_tool`. Each call runs here as an + ordinary tool call, with the run's context, the tool's guardrails, + and its `needs_approval`, and its result goes back to the program. + The Blueprint must grant `tools.call`. The model is not given these + tools directly; add them to the agent as well for that. + + Example: + ```python + from agents import Agent, Runner + from openai_agents_submilli import SubmilliTools + + submilli = SubmilliTools(blueprint="research") + agent = Agent(name="researcher", tools=submilli.tools) + await Runner.run(agent, "...", context={"user_id": "u_ada", "conversation_id": "c_1"}) + ``` + """ + + def __init__( + self, + blueprint: str, + server: str | None = None, + token: str | None = None, + variables: ContextMapper | Mapping[str, str] | None = None, + secrets: ContextMapper | Mapping[str, str] | None = None, + *, + conversation_id: ConversationMapper | None = None, + store: SessionStore | None = None, + client: AsyncSubmilli | None = None, + ptc: Sequence[FunctionTool | Callable[..., Any]] | None = None, + ) -> None: + self.blueprint: str = blueprint + self.client: AsyncSubmilli = client or AsyncSubmilli(base_url=server, token=token) + self.store: SessionStore = store if store is not None else InMemorySessionStore() + self._owns_client = client is None + self._variables = _mapper(variables, user_id_variables) + self._secrets = _mapper(secrets, no_secrets) + self._conversation = conversation_id or context_conversation_id + self._program_tools = ProgramTools(as_function_tools(ptc)) + self._program_agent = program_agent(self.client, self._program_tools) if self._program_tools else None + self._described = False + self._describe_lock: asyncio.Lock | None = None + self._locks: dict[str, asyncio.Lock] = {} + self._data: OrderedDict[str, dict[str, Any]] = OrderedDict() + self.tools: list[FunctionTool] = self._build_tools() + + # Conversations + + async def aclose_conversation(self, conversation_id: str) -> bool: + """End the conversation's session, and forget it. + + Returns `False` when the conversation had no session or the server had + already ended it. Its next program opens a new session, which may bind + another user. + """ + session = await self.store.get(conversation_id) + if session is None: + return False + deleted = await self.client.sessions.delete(session["id"]) + await self.store.set(conversation_id, None) + return deleted + + async def aclose(self) -> None: + """Close the HTTP connections of the client this toolkit built. Sessions stay open.""" + if self._owns_client: + await self.client.aclose() + + # Tools + + def _build_tools(self) -> list[FunctionTool]: + handlers: dict[str, Callable[[ToolContext[Any], dict[str, Any]], Awaitable[str]]] = { + t.EXECUTE: self._execute, + t.LAST_RUN: self._last_run, + t.PACKAGES_SEARCH: self._packages_search, + t.PACKAGES_DOCS: self._packages_docs, + t.BUILTINS_LIST: self._builtins_list, + t.BUILTINS_DOCS: self._builtins_docs, + } + tools: list[FunctionTool] = [] + for spec in t.SPECS: + handler = handlers[spec.name] + + async def invoke( + context: ToolContext[Any], + arguments: str, + handler: Callable[[ToolContext[Any], dict[str, Any]], Awaitable[str]] = handler, + ) -> str: + return await handler(context, json.loads(arguments or "{}")) + + tool = FunctionTool( + name=spec.name, + description=spec.fallback, + params_json_schema=spec.params, + on_invoke_tool=invoke, + is_enabled=self._prepare, + ) + if spec.name == t.EXECUTE: + tool = self._execute_tool(tool) + tools.append(tool) + return tools + + def _execute_tool(self, tool: FunctionTool) -> FunctionTool: + """The execute tool, which reports its ids in `custom_data`. + + With application tools it is built as the nested agent's tool, so that + the SDK keeps, serializes and resumes the nested run of a program + interrupted for an approval. + """ + if self._program_agent is not None: + template = self._program_agent.as_tool(t.EXECUTE, tool.description, failure_error_function=None) + tool = dataclasses.replace( + template, + params_json_schema=tool.params_json_schema, + on_invoke_tool=tool.on_invoke_tool, + is_enabled=self._prepare_execute, + ) + else: + tool.is_enabled = self._prepare_execute + tool.custom_data_extractor = self._custom_data + return tool + + async def _prepare(self, context: RunContextWrapper[Any], agent: Any) -> bool: + """Before each model call: put the Blueprint's descriptions on the tools.""" + await self._describe() + return True + + async def _prepare_execute(self, context: RunContextWrapper[Any], agent: Any) -> bool: + """Before each model call: also refuse a run whose variables differ from the session's.""" + await self._describe() + conversation = self._conversation(context.context) + if conversation is not None: + session = self._own(await self.store.get(conversation)) + if session is not None: + self._check_binding(session, self._variables(context.context)) + return True + + def _custom_data(self, data: FunctionToolCustomDataContext) -> dict[str, Any] | None: + return self._data.pop(data.tool_context.tool_call_id, None) + + async def _execute(self, context: ToolContext[Any], arguments: dict[str, Any]) -> str: + code = str(arguments["code"]) + conversation = self._require_conversation(context.context) + session = await self._session(conversation, context.context) + key = idempotency_key(context.tool_call_id) if context.tool_call_id else None + notice = None + rebound = False + while True: + try: + output = await self._run(context, code, session["id"], key, notice) + break + except UnknownSessionError: + if notice is not None: + raise + session = await self._replace(conversation, session, context.context) + notice = t.SESSION_REPLACED_NOTE + except SessionRequiresSecretsError as error: + if rebound: + raise SubmilliError( + f"submilli-server still asks for harness secrets {error.required} after a rebind; " + "check the `secrets` given to SubmilliTools" + ) from error + await self.client.sessions.rebind(session["id"], self._secrets(context.context)) + rebound = True + if isinstance(output, str): + return output + content, data = output + self._remember(context.tool_call_id, data) + return json.dumps(content, ensure_ascii=False) + + async def _run( + self, context: ToolContext[Any], code: str, session_id: str, key: str | None, notice: str | None + ) -> t.Outcome | str: + """Run the program once: its outcome, or the text to hand the SDK when an approval interrupted it. + + Raises `UnknownSessionError` and `SessionRequiresSecretsError` for the + caller to replace the session or rebind its secrets, which needs the + run's context. + """ + if self._program_agent is None: + try: + state = await self.client.sessions.execute(session_id, code, idempotency_key=key) + except IdempotencyError as error: + return t.idempotency_outcome(error, session_id=session_id) + except RunStateError as error: + return t.run_state_outcome(error, session_id=session_id, tool_calls=[]) + if not isinstance(state, ExecuteResult): + raise SubmilliError( + f"run {state.execution_id} did not finish, in a session opened without application tools" + ) + return t.execute_outcome(state, notice=notice) + # With application tools the nested agent runs the program, from its + # first execute on. A program resumed after an approval continues from + # the nested run the SDK saved, whose input names the session it began + # in, so it is never started again in another session. + program = ProgramInput(code=code, session_id=session_id, idempotency_key=key, notice=notice) + nested = self._program_agent.as_tool( + t.EXECUTE, + "", + failure_error_function=None, + run_config=nested_run_config(context.run_config), + max_turns=MAX_PROGRAM_CALLS, + ) + output = await nested.on_invoke_tool(context, json.dumps({"input": program.to_json()})) + outcome = unwrap(output) + # `None` when interrupted for an approval: the SDK holds the output back. + return outcome if outcome is not None else t.as_text(output) + + async def _last_run(self, context: ToolContext[Any], arguments: dict[str, Any]) -> str: + conversation = self._require_conversation(context.context) + session = self._own(await self.store.get(conversation)) + if session is None: + return "No program has run in this conversation yet." + run = await self.client.sessions.last_run(session["id"]) + if run is None: + return ( + "No run is recorded for this conversation's session. " + "The server keeps runs in memory, so a restart clears them." + ) + return t.as_text(run.raw) + + async def _packages_search(self, context: ToolContext[Any], arguments: dict[str, Any]) -> str: + query = str(arguments.get("query") or "") + if not self._program_tools: + return t.as_text(await self.client.blueprints.search_packages(self.blueprint, query)) + + async def search(session_id: str) -> str: + return t.as_text(await self.client.sessions.search_packages(session_id, query)) + + return await self._in_session(context, search) + + async def _packages_docs(self, context: ToolContext[Any], arguments: dict[str, Any]) -> str: + name = str(arguments["name"]) + if not self._program_tools: + return (await self.client.blueprints.package_docs(self.blueprint, name)).text + + async def docs(session_id: str) -> str: + return (await self.client.sessions.package_docs(session_id, name)).text + + return await self._in_session(context, docs) + + async def _in_session(self, context: ToolContext[Any], fetch: Callable[[str], Awaitable[str]]) -> str: + """Discovery that includes the session's own `@tools`, so it needs the conversation's session.""" + conversation = self._require_conversation(context.context) + session = await self._session(conversation, context.context) + try: + return await fetch(session["id"]) + except UnknownSessionError: + session = await self._replace(conversation, session, context.context) + return await fetch(session["id"]) + + async def _builtins_list(self, context: ToolContext[Any], arguments: dict[str, Any]) -> str: + return t.as_text(await self.client.blueprints.builtins(self.blueprint)) + + async def _builtins_docs(self, context: ToolContext[Any], arguments: dict[str, Any]) -> str: + names = [str(name) for name in arguments.get("names") or []] + return t.as_text(await self.client.blueprints.builtin_docs(self.blueprint, names)) + + # Sessions + + def _require_conversation(self, context: Any) -> str: + conversation = self._conversation(context) + if conversation is None: + raise SubmilliConversationError( + "The run's context names no conversation, so Submilli can't tell which session its programs " + "belong to. Pass a context with a `conversation_id` (for example the id of the agent's SDK " + "session), or give SubmilliTools a `conversation_id` function." + ) + return conversation + + def _own(self, session: SubmilliSession | None) -> SubmilliSession | None: + """The stored session, if it was opened on this server for this Blueprint and these tools.""" + if session is None: + return None + if session.get("server") != self.client.base_url or session.get("blueprint") != self.blueprint: + return None + if session.get("tools") != self._program_tools.digest: + # Opened with other application tools, which a session keeps for life. + return None + return session + + async def _session(self, conversation: str, context: Any) -> SubmilliSession: + """The conversation's session, opened if it has none.""" + variables = self._variables(context) + session = self._own(await self.store.get(conversation)) + if session is None: + async with self._lock(conversation): + session = self._own(await self.store.get(conversation)) + if session is None: + session = await self._open(context) + await self.store.set(conversation, session) + self._check_binding(session, variables) + return session + + async def _replace(self, conversation: str, ended: SubmilliSession, context: Any) -> SubmilliSession: + """A new session for a conversation whose session ended, shared by calls that found it ended.""" + async with self._lock(conversation): + current = self._own(await self.store.get(conversation)) + if current is not None and current["id"] != ended["id"]: + return current + session = await self._open(context) + await self.store.set(conversation, session) + return session + + def _check_binding(self, session: SubmilliSession, variables: dict[str, str]) -> None: + if session["variables"] == variables: + return + differing = sorted( + name + for name in set(session["variables"]) | set(variables) + if session["variables"].get(name) != variables.get(name) + ) + raise SubmilliSessionMismatchError( + "This conversation's Submilli session was opened with different variables than this run's context " + f"gives (differing: {', '.join(differing)}). A session is never shared between bindings: run this " + "context in a new conversation, or end the conversation's session first with " + "`SubmilliTools.aclose_conversation`." + ) + + async def _open(self, context: Any) -> SubmilliSession: + variables = self._variables(context) + created = await self.client.sessions.create( + self.blueprint, + variables=variables, + secrets=self._secrets(context), + tools=self._program_tools.catalog or None, + ) + for warning in created.warnings: + logger.warning("submilli-server: %s", warning) + logger.debug("opened Submilli session %s for blueprint %s", created.id, self.blueprint) + session = SubmilliSession( + id=created.id, server=self.client.base_url, blueprint=self.blueprint, variables=variables + ) + if self._program_tools: + session["tools"] = self._program_tools.digest + return session + + def _lock(self, conversation: str) -> asyncio.Lock: + lock = self._locks.get(conversation) + if lock is None: + if len(self._locks) > 1024: + for idle in [name for name, held in self._locks.items() if not held.locked()]: + del self._locks[idle] + lock = self._locks[conversation] = asyncio.Lock() + return lock + + def _remember(self, tool_call_id: str, data: dict[str, Any]) -> None: + self._data[tool_call_id] = data + while len(self._data) > _MAX_PENDING_DATA: + self._data.popitem(last=False) + + async def _describe(self) -> None: + """Fetch the Blueprint's tool descriptions once, and put them on the tools.""" + if self._described: + return + if self._describe_lock is None: + self._describe_lock = asyncio.Lock() + async with self._describe_lock: + if self._described: + return + described = t.descriptions(await self.client.blueprints.prompt(self.blueprint)) + if self._program_tools: + described[t.EXECUTE] += self._program_tools.prompt() + for tool in self.tools: + tool.description = described[tool.name] + self._described = True + + +def _mapper(value: ContextMapper | Mapping[str, str] | None, default: ContextMapper) -> Callable[[Any], dict[str, str]]: + if value is None: + source: ContextMapper = default + elif callable(value): + source = value + else: + fixed = dict(value) + source = lambda _: fixed # noqa: E731 + + def mapped(context: Any) -> dict[str, str]: + values = dict(source(context) or {}) + for name, item in values.items(): + if not isinstance(name, str) or not isinstance(item, str): + raise TypeError(f"Submilli variables and secrets must be strings; got {name!r}: {type(item).__name__}") + return values + + return mapped + + +def _context_value(context: Any, name: str) -> Any: + if context is None: + return None + if isinstance(context, Mapping): + return context.get(name) + return getattr(context, name, None) diff --git a/packages/openai-agents-submilli/src/openai_agents_submilli/_tools.py b/packages/openai-agents-submilli/src/openai_agents_submilli/_tools.py new file mode 100644 index 0000000..190c3ad --- /dev/null +++ b/packages/openai-agents-submilli/src/openai_agents_submilli/_tools.py @@ -0,0 +1,263 @@ +"""The six Submilli tools: names, argument schemas, and what the model is shown. + +Results are dictionaries here. The toolkit turns them into the JSON text the +model reads, and keeps the ids for the application in the output's +`custom_data`. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any + +from submilli import BlueprintPrompt, ExecuteResult, IdempotencyError, RunStateError + +# The server's own names, as its MCP endpoint lists them. +EXECUTE = "submilli__typescript__execute" +LAST_RUN = "submilli__typescript__last_run" +PACKAGES_SEARCH = "submilli__typescript__packages__search" +PACKAGES_DOCS = "submilli__typescript__packages__docs" +BUILTINS_LIST = "submilli__typescript__builtins__list" +BUILTINS_DOCS = "submilli__typescript__builtins__docs" + +TOOL_NAMES = (EXECUTE, LAST_RUN, PACKAGES_SEARCH, PACKAGES_DOCS, BUILTINS_LIST, BUILTINS_DOCS) + + +def _object(properties: dict[str, Any]) -> dict[str, Any]: + # Strict mode: every property is required, and no others are allowed. + return { + "type": "object", + "properties": properties, + "required": list(properties), + "additionalProperties": False, + } + + +EXECUTE_PARAMS = _object( + { + "code": { + "type": "string", + "description": "TypeScript-subset source with a `main()` entry point. Its return value is the tool result.", + } + } +) +NO_PARAMS = _object({}) +PACKAGES_SEARCH_PARAMS = _object( + { + "query": { + "type": "string", + "description": "Substring matched against module names, descriptions, and exported symbols. " + 'Empty ("") lists every available package.', + } + } +) +PACKAGES_DOCS_PARAMS = _object({"name": {"type": "string", "description": "Package name, e.g. `submilli:crypto`."}}) +BUILTINS_DOCS_PARAMS = _object( + { + "names": { + "type": "array", + "items": {"type": "string"}, + "description": 'Built-in names to look up, e.g. `["Array", "Map", "Temporal"]`. Each returns its ' + "declarations; unknown names come back with an `error` entry.", + } + } +) + + +@dataclass(frozen=True) +class Spec: + name: str + params: dict[str, Any] + # Used until the Blueprint's own descriptions have been fetched. + fallback: str + + +SPECS = ( + Spec(EXECUTE, EXECUTE_PARAMS, "Run a TypeScript program on submilli-server under the Blueprint's policy."), + Spec(LAST_RUN, NO_PARAMS, "Return the most recent execution's result and full console for this conversation."), + Spec(PACKAGES_SEARCH, PACKAGES_SEARCH_PARAMS, "Search the Packages a program may import."), + Spec(PACKAGES_DOCS, PACKAGES_DOCS_PARAMS, "Return a Package's documentation and TypeScript declarations."), + Spec(BUILTINS_LIST, NO_PARAMS, "List the built-ins in scope without an import."), + Spec(BUILTINS_DOCS, BUILTINS_DOCS_PARAMS, "Return the declarations of built-ins."), +) + + +def descriptions(prompt: BlueprintPrompt) -> dict[str, str]: + """The description of each tool, from the Blueprint's prompt endpoint.""" + return { + EXECUTE: prompt.prompt, + LAST_RUN: prompt.tools["last_run"], + PACKAGES_SEARCH: prompt.tools["search"], + PACKAGES_DOCS: prompt.tools["docs"], + BUILTINS_LIST: prompt.tools["builtins_list"], + BUILTINS_DOCS: prompt.tools["builtins_docs"], + } + + +# What the model is shown. Each builder returns `(content, data)`: the model +# reads `content`, and `data` goes to the application in `custom_data`. + +Outcome = tuple[dict[str, Any], dict[str, Any]] + +DENIAL_NOTE = ( + "The operator's policy denied this. The denial is final: tell the user what was refused, " + "and do not try another route to the same effect." +) + +SESSION_REPLACED_NOTE = ( + "This conversation's previous Submilli session had ended (idle timeout, or the server no longer " + "knows it), so a new session was opened for this call. Files the earlier session kept outside " + "persistent volumes, and its `submilli:session` state, are gone." +) + +IDEMPOTENCY_MESSAGES = { + "idempotency_conflict": ( + "Nothing ran. This tool call id was already used in this session with different code. " + "Make a new tool call to run this program." + ), + "idempotency_incomplete": ( + "Nothing ran now. An earlier attempt of this same tool call started, but the server stopped " + "before it recorded the outcome, so its side effects are unknown: it may have done some, all, " + "or none of its work. Check what it would have changed (read the files, look up the records) " + "before running anything that repeats it, and tell the user if you cannot tell." + ), + "idempotency_in_progress": ( + "Nothing new ran. An earlier attempt of this same tool call is still running on the server and " + "did not finish within the server's wait. Check its effects, or the last run, before trying again." + ), + "idempotency_unavailable": ( + "Nothing ran. The server could not read or write its idempotency record, so it refused to run " + "the program rather than risk running it twice. Trying again later may work." + ), + "idempotency_key_invalid": "Nothing ran. The server refused this call's idempotency key.", + "idempotency_response_expired": ( + "Nothing new ran. The earlier program finished, but the server no longer keeps its result. " + "Check its effects before running anything that repeats it; changing the key would run it again." + ), +} + + +def execute_outcome( + result: ExecuteResult, *, notice: str | None = None, tool_calls: list[dict[str, Any]] | None = None +) -> Outcome: + """A finished run: the model gets `result`, `console`, `error`, `denied` and the tool calls.""" + content: dict[str, Any] = { + "result": result.result, + "console": result.console, + "error": result.error.raw if result.error else None, + "denied": result.denied, + } + if result.denied: + content["note"] = DENIAL_NOTE + if result.discovery_warnings: + content["discovery_warnings"] = result.discovery_warnings + if notice: + content["notice"] = notice + data: dict[str, Any] = {"execution_id": result.execution_id, "session_id": result.session_id} + _add_tool_calls(content, data, tool_calls) + return content, data + + +def _add_tool_calls(content: dict[str, Any], data: dict[str, Any], tool_calls: list[dict[str, Any]] | None) -> None: + """Report the program's calls to the application's tools, in order. + + The model gets them without their ids, so that after a refusal or a lost run it can tell + the user which calls already took effect; the application gets them whole. + """ + if not tool_calls: + return + content["tool_calls"] = [{key: value for key, value in call.items() if key != "call_id"} for call in tool_calls] + data["tool_calls"] = tool_calls + + +LOST_RUN_MESSAGE = ( + "The run was lost while it waited on one of the application's tools: the server restarted or no " + "longer holds it. Apart from the application tool calls in `tool_calls`, if any, its effects up to that " + "point are unknown. Check what it would have changed before " + "running anything that repeats it, and tell the user if you cannot tell." +) + + +def lost_run_outcome(*, execution_id: str, session_id: str, tool_calls: list[dict[str, Any]]) -> Outcome: + """A run the server lost while it was suspended.""" + content: dict[str, Any] = { + "result": None, + "console": [], + "error": {"kind": "run_lost", "message": LOST_RUN_MESSAGE}, + "denied": False, + } + data: dict[str, Any] = {"execution_id": execution_id, "session_id": session_id, "refusal": "unknown_run"} + _add_tool_calls(content, data, tool_calls) + return content, data + + +_UNKNOWN_EFFECTS = ( + "Check what it would have changed before running anything that repeats it, and tell the user if you cannot tell." +) +_NO_RESULT = ( + "run_result_unavailable", + "The run finished, but the server no longer keeps its result. Check its effects, or the last run, " + "before running anything that repeats it.", +) + +# By run status: the error kind the model sees, and what it is told. +RUN_STATE_MESSAGES = { + "stuck": ( + "run_stuck", + "The run stopped before it finished, and the server holds it until an operator recovers it. Its " + "effects up to that point are unknown, and programs run after it in this session wait until it is " + "recovered. Tell the user before running anything else.", + ), + "cancelled": ( + "run_cancelled", + "The run was cancelled before it finished, because its session was closed. Its effects up to that " + "point are unknown. " + _UNKNOWN_EFFECTS, + ), + "aborted": ("run_aborted", "The run was abandoned before the program started, so nothing ran."), + "indeterminate": ( + "run_indeterminate", + "The run was interrupted, and the server cannot tell how far it got: it may have done some, all, " + "or none of its work. " + _UNKNOWN_EFFECTS, + ), + "expired": _NO_RESULT, + "completed": _NO_RESULT, +} + + +def run_state_outcome(error: RunStateError, *, session_id: str, tool_calls: list[dict[str, Any]]) -> Outcome: + """A run the server reports with no result to return, such as a durable run that is `stuck`.""" + kind, message = RUN_STATE_MESSAGES.get( + error.status, + ( + "run_state_unknown", + f"The server reported the run as {error.status!r}, which this version of openai-agents-submilli " + "does not know, so its outcome is unknown. " + _UNKNOWN_EFFECTS, + ), + ) + reported: dict[str, Any] = {"kind": kind, "message": message, "status": error.status} + if error.reason: + reported["reason"] = error.reason + content: dict[str, Any] = {"result": None, "console": [], "error": reported, "denied": False} + data: dict[str, Any] = {"execution_id": error.execution_id, "session_id": session_id, "run_status": error.status} + _add_tool_calls(content, data, tool_calls) + return content, data + + +def idempotency_outcome(error: IdempotencyError, *, session_id: str) -> Outcome: + """An idempotency refusal, explained to the model. Nothing ran.""" + message = IDEMPOTENCY_MESSAGES.get(error.code or "", f"Nothing ran. The server refused the call: {error.detail}") + content: dict[str, Any] = { + "result": None, + "console": [], + "error": {"kind": error.code, "message": message, "detail": error.detail}, + "denied": False, + } + data: dict[str, Any] = {"session_id": session_id, "refusal": error.code} + if error.execution_id: + data["execution_id"] = error.execution_id + return content, data + + +def as_text(value: Any) -> str: + return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False) diff --git a/packages/openai-agents-submilli/src/openai_agents_submilli/py.typed b/packages/openai-agents-submilli/src/openai_agents_submilli/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/openai-agents-submilli/tests/__init__.py b/packages/openai-agents-submilli/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/openai-agents-submilli/tests/integration_tests/__init__.py b/packages/openai-agents-submilli/tests/integration_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/openai-agents-submilli/tests/integration_tests/conftest.py b/packages/openai-agents-submilli/tests/integration_tests/conftest.py new file mode 100644 index 0000000..6d8000e --- /dev/null +++ b/packages/openai-agents-submilli/tests/integration_tests/conftest.py @@ -0,0 +1,108 @@ +"""A submilli-server for the integration tests. + +`managed` is a server these tests start, stop and restart themselves. It needs +the `submilli-server` binary, from `SUBMILLI_BIN` or `PATH`, and is skipped +without it. Blueprints are registered over the admin API. +""" + +from __future__ import annotations + +import os +import re +import secrets +import shutil +import socket +import subprocess +import time +from collections.abc import Iterator +from pathlib import Path +from typing import cast + +import httpx +import pytest + + +def _binary(name: str) -> str | None: + directory = os.environ.get("SUBMILLI_BIN") + if directory: + path = Path(directory) / name + return str(path) if path.exists() else None + return shutil.which(name) + + +def _free_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return cast("int", sock.getsockname()[1]) + + +class ManagedServer: + """A submilli-server in a temporary home, which a test may restart.""" + + def __init__(self, home: Path) -> None: + server_bin = _binary("submilli-server") + if not server_bin: + pytest.skip("submilli-server not found (set SUBMILLI_BIN)") + self.server_bin = server_bin + self.home = home + self.port = _free_port() + self.url = f"http://127.0.0.1:{self.port}" + self.token = secrets.token_hex(32) + self.process: subprocess.Popen[bytes] | None = None + (home / "store.key").write_text(__import__("base64").b64encode(os.urandom(32)).decode()) + config = f"secret_store:\n key_file: {home / 'store.key'}\n" + (home / "server.yaml").write_text(config) + self.env = { + **os.environ, + "SUBMILLI_HOME": str(home), + "SUBMILLI_SERVER_TOKEN": self.token, + "SUBMILLI_SERVER_URL": self.url, + } + + def start(self) -> None: + log = open(self.home / "server.log", "ab") # noqa: SIM115 + self.process = subprocess.Popen( + [self.server_bin, "--config", str(self.home / "server.yaml"), "--port", str(self.port)], + env=self.env, + stdout=log, + stderr=log, + ) + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + try: + httpx.get(f"{self.url}/v1/blueprints/none/builtins", timeout=1) + return + except httpx.TransportError: + time.sleep(0.1) + raise RuntimeError(f"submilli-server did not start; see {self.home / 'server.log'}") + + def stop(self) -> None: + if self.process is not None: + self.process.terminate() + self.process.wait(timeout=30) + self.process = None + + def restart(self) -> None: + self.stop() + self.start() + + def apply(self, blueprint: str) -> None: + name = re.search(r"^name: (\S+)$", blueprint, re.MULTILINE) + assert name, "the Blueprint has no name" + response = httpx.put( + f"{self.url}/v1/blueprints/{name[1]}", + json={"yaml": blueprint}, + headers={"Authorization": f"Bearer {self.token}"}, + timeout=30, + ) + response.raise_for_status() + + +@pytest.fixture +def managed(tmp_path: Path) -> Iterator[ManagedServer]: + server = ManagedServer(tmp_path) + server.start() + try: + yield server + finally: + server.stop() diff --git a/packages/openai-agents-submilli/tests/integration_tests/test_server.py b/packages/openai-agents-submilli/tests/integration_tests/test_server.py new file mode 100644 index 0000000..a0ed863 --- /dev/null +++ b/packages/openai-agents-submilli/tests/integration_tests/test_server.py @@ -0,0 +1,339 @@ +"""`SubmilliTools` on a real submilli-server, with a scripted model. + +These start their own server (`managed`), which must be 0.4.1 or later. +""" + +from __future__ import annotations + +import json +import time +from pathlib import Path +from typing import Any + +import pytest +from agents import Agent, RunContextWrapper, Runner, RunResult, RunState, function_tool, set_tracing_disabled +from agents.items import ToolCallOutputItem +from typing_extensions import TypedDict + +from openai_agents_submilli import SQLiteSessionStore, SubmilliSessionMismatchError, SubmilliTools +from tests.integration_tests.conftest import ManagedServer +from tests.scripted import Scripted, call, calls_then_done, execute + +pytestmark = pytest.mark.integration + +set_tracing_disabled(True) + +SHOP = """kind: blueprint +name: shop +vfs: none +variables: + userId: + required: true +default: deny +permissions: + main: + - capability: tools.call + filter: tool == "query_orders" or tool == "send_email" or tool == "broken" + action: allow +""" + +sent: list[dict[str, str]] = [] + + +class Orders(TypedDict): + count: int + for_user: int + + +@function_tool +def query_orders(wrapper: RunContextWrapper[dict[str, str]], customer: str) -> Orders: + """Orders of one customer.""" + return {"count": len(customer), "for_user": len(wrapper.context["user_id"])} + + +@function_tool(needs_approval=True) +def send_email(to: str, body: str) -> str: + """Send an email.""" + sent.append({"to": to, "body": body}) + return f"sent to {to}" + + +@function_tool +def broken() -> str: + """Always fails.""" + raise RuntimeError("database is down") + + +@function_tool +def refund(order: str) -> str: + """Refund an order. The Blueprint does not allow it.""" + raise AssertionError("a tool the Blueprint denies must never run") + + +@pytest.fixture(autouse=True) +def clear_sent() -> None: + sent.clear() + + +def ada(conversation: str = "c_1") -> dict[str, str]: + return {"user_id": "u_ada", "conversation_id": conversation} + + +def shop(managed: ManagedServer, model: Scripted, store: SQLiteSessionStore | None = None) -> Agent[Any]: + managed.apply(SHOP) + submilli = SubmilliTools( + "shop", + server=managed.url, + token=managed.token, + store=store, + ptc=[query_orders, send_email, broken, refund], + ) + return Agent(name="assistant", model=model, tools=list(submilli.tools)) + + +def tool_outputs(result: RunResult) -> list[ToolCallOutputItem]: + return [item for item in result.new_items if isinstance(item, ToolCallOutputItem)] + + +def body(item: ToolCallOutputItem) -> dict[str, Any]: + parsed: dict[str, Any] = json.loads(item.output) + return parsed + + +ORDERS = """ +import tools from "@tools"; + +function main(): string { + const orders = tools.query_orders({ customer: "c_42" }); + return `${orders.count} orders, user ${orders.for_user}`; +} +""" + + +async def test_a_program_calls_a_typed_tool_with_the_runs_context(managed: ManagedServer) -> None: + result = await Runner.run(shop(managed, calls_then_done(execute(ORDERS))), "go", context=ada()) + [output] = tool_outputs(result) + assert body(output)["error"] is None, body(output) + assert body(output)["result"] == "4 orders, user 5" + assert output.custom_data is not None + assert output.custom_data["tool_calls"][0]["tool"] == "query_orders" + + +async def test_package_docs_show_the_tools_signatures(managed: ManagedServer) -> None: + model = calls_then_done(call("submilli__typescript__packages__docs", {"name": "@tools"})) + [output] = tool_outputs(await Runner.run(shop(managed, model), "go", context=ada())) + assert "function query_orders(args: { customer: string }): { count: number; for_user: number };" in output.output + + +CATCHES = """ +import tools from "@tools"; + +function main(): string { + try { + tools.broken(); + return "no error"; + } catch (e) { + return `caught: ${(e as Error).message}`; + } +} +""" + + +async def test_a_failing_tool_throws_in_the_program(managed: ManagedServer) -> None: + [output] = tool_outputs(await Runner.run(shop(managed, calls_then_done(execute(CATCHES))), "go", context=ada())) + assert "caught:" in body(output)["result"] and "database is down" in body(output)["result"], body(output) + + +REFUND = """ +import tools from "@tools"; + +function main(): string { + return tools.refund({ order: "o_1" }); +} +""" + + +async def test_the_blueprint_decides_which_tools_programs_may_call(managed: ManagedServer) -> None: + [output] = tool_outputs(await Runner.run(shop(managed, calls_then_done(execute(REFUND))), "go", context=ada())) + # `@tools` lists every declared tool; the Blueprint refuses the call, and + # the application never sees it. + error = body(output)["error"] + assert error["kind"] == "permission_denied" and error["source"] == "policy", error + assert body(output)["denied"] is True + assert "tool_calls" not in (output.custom_data or {}) + + +TWO_EMAILS = """ +import tools from "@tools"; + +function main(): string { + const first = tools.send_email({ to: "first@example.com", body: "1" }); + let second = ""; + try { + second = tools.send_email({ to: "second@example.com", body: "2" }); + } catch (e) { + second = `refused: ${(e as Error).message}`; + } + return `${first} | ${second}`; +} +""" + + +async def test_each_call_waits_for_its_own_approval_across_processes(managed: ManagedServer, tmp_path: Path) -> None: + database = tmp_path / "app.db" + assistant = shop(managed, calls_then_done(execute(TWO_EMAILS)), SQLiteSessionStore(database)) + paused = await Runner.run(assistant, "go", context=ada()) + [first] = paused.interruptions + assert first.tool_name == "send_email" + assert json.loads(first.arguments or "{}")["to"] == "first@example.com" + saved = paused.to_state().to_string() + + # Another process: a new toolkit and agent, the saved state, the same database. + assistant = shop(managed, Scripted("done"), SQLiteSessionStore(database)) + state = await RunState.from_string(assistant, saved) + state.approve(state.get_interruptions()[0]) + paused = await Runner.run(assistant, state) + [second] = paused.interruptions + assert json.loads(second.arguments or "{}")["to"] == "second@example.com" + state = paused.to_state() + state.reject(second, rejection_message="Only one email today.") + result = await Runner.run(assistant, state) + + assert sent == [{"to": "first@example.com", "body": "1"}] + [output] = tool_outputs(result) + assert body(output)["error"] is None, body(output) + assert body(output)["result"].startswith("sent to first@example.com | refused: ") + assert "Only one email today." in body(output)["result"] + assert output.custom_data is not None + assert [record["outcome"] for record in output.custom_data["tool_calls"]] == ["ok", "denied"] + + +async def test_an_approved_call_resumes_its_run_even_without_the_store(managed: ManagedServer) -> None: + paused = await Runner.run(shop(managed, calls_then_done(execute(TWO_EMAILS))), "go", context=ada()) + saved = paused.to_state().to_string() + + # Another process with a fresh in-memory store: the saved nested run names its session. + assistant = shop(managed, Scripted("done")) + state = await RunState.from_string(assistant, saved) + state.approve(state.get_interruptions()[0]) + paused = await Runner.run(assistant, state) + [second] = paused.interruptions + assert json.loads(second.arguments or "{}")["to"] == "second@example.com" + state = paused.to_state() + state.approve(second) + [output] = tool_outputs(await Runner.run(assistant, state)) + + assert [email["to"] for email in sent] == ["first@example.com", "second@example.com"] + assert body(output)["result"] == "sent to first@example.com | sent to second@example.com", body(output) + + +def scratch_blueprint(name: str, idle_timeout: str = "1h") -> str: + return f"""kind: blueprint +name: {name} +idle_timeout: '{idle_timeout}' +variables: + userId: + required: true +secrets: + USER_TOKEN: + harness: + required: true +vfs: + mode: per_session +default: deny +permissions: + main: + - {{capability: fs.read, action: allow}} + - {{capability: fs.write, action: allow}} + - {{capability: fs.stat, action: allow}} + - {{capability: fs.list, action: allow}} +""" + + +SCRATCH = """ +import fs from "submilli:fs"; + +function main(): string { + const before = fs.exists("scratch.txt"); + fs.writeText("scratch.txt", "x"); + return before ? "scratch was there" : "scratch was not there"; +} +""" + + +def scratch(managed: ManagedServer, blueprint: str) -> SubmilliTools: + return SubmilliTools( + blueprint, + server=managed.url, + token=managed.token, + secrets=lambda context: {"USER_TOKEN": context["token"]}, + ) + + +async def ask(submilli: SubmilliTools, context: dict[str, str], **call: Any) -> dict[str, Any]: + model = calls_then_done(execute(SCRATCH, **call)) + [output] = tool_outputs( + await Runner.run(Agent(name="a", model=model, tools=list(submilli.tools)), "go", context=context) + ) + return {**body(output), "data": output.custom_data} + + +async def test_a_conversation_keeps_its_session_and_others_do_not_share_it(managed: ManagedServer) -> None: + managed.apply(scratch_blueprint("scratch")) + submilli = scratch(managed, "scratch") + first = await ask(submilli, {**ada(), "token": "t"}) + again = await ask(submilli, {**ada(), "token": "t"}) + other = await ask(submilli, {**ada("c_2"), "token": "t"}) + assert first["result"] == "scratch was not there" + assert again["result"] == "scratch was there" + assert other["result"] == "scratch was not there" + assert first["data"]["session_id"] == again["data"]["session_id"] != other["data"]["session_id"] + assert await submilli.aclose_conversation("c_1") is True + closed = await ask(submilli, {**ada(), "token": "t"}) + assert closed["result"] == "scratch was not there" + + +async def test_the_same_tool_call_runs_once(managed: ManagedServer) -> None: + managed.apply(scratch_blueprint("scratch")) + submilli = scratch(managed, "scratch") + first = await ask(submilli, {**ada(), "token": "t"}, id="call_once") + replay = await ask(submilli, {**ada(), "token": "t"}, id="call_once") + assert first["result"] == replay["result"] == "scratch was not there" + assert first["data"]["execution_id"] == replay["data"]["execution_id"] + + +async def test_another_user_in_the_conversation_is_refused(managed: ManagedServer) -> None: + managed.apply(scratch_blueprint("scratch")) + submilli = scratch(managed, "scratch") + await ask(submilli, {**ada(), "token": "t"}) + with pytest.raises(SubmilliSessionMismatchError): + await ask(submilli, {"user_id": "u_grace", "conversation_id": "c_1", "token": "t"}) + + +async def test_a_server_restart_keeps_the_session(managed: ManagedServer) -> None: + managed.apply(scratch_blueprint("scratch")) + submilli = scratch(managed, "scratch") + first = await ask(submilli, {**ada(), "token": "t"}) + managed.restart() + second = await ask(submilli, {**ada(), "token": "t"}) + assert second["result"] == "scratch was there" + assert "notice" not in second + assert second["data"]["session_id"] == first["data"]["session_id"] + + +async def test_an_expired_session_is_replaced_and_the_model_told(managed: ManagedServer) -> None: + managed.apply(scratch_blueprint("expiring", idle_timeout="1s")) + submilli = scratch(managed, "expiring") + first = await ask(submilli, {**ada(), "token": "t"}) + + # Idle past the timeout while the server is down; it reaps on start. + managed.stop() + time.sleep(1.5) + managed.start() + second = await ask(submilli, {**ada(), "token": "t"}) + + assert second["result"] == "scratch was not there" + assert "earlier session" in second["notice"] + assert second["data"]["session_id"] != first["data"]["session_id"] + stored = await submilli.store.get("c_1") + assert stored is not None and stored["id"] == second["data"]["session_id"] diff --git a/packages/openai-agents-submilli/tests/scripted.py b/packages/openai-agents-submilli/tests/scripted.py new file mode 100644 index 0000000..68fa1ca --- /dev/null +++ b/packages/openai-agents-submilli/tests/scripted.py @@ -0,0 +1,110 @@ +"""A scripted model, as in submilli-runtime's `check.py`: no API key needed.""" + +from __future__ import annotations + +import itertools +import json +from collections.abc import AsyncIterator +from typing import Any + +from agents import FunctionTool, ModelResponse, ModelSettings, Tool, Usage +from agents.agent_output import AgentOutputSchemaBase +from agents.handoffs import Handoff +from agents.items import TResponseInputItem, TResponseStreamEvent +from agents.models.interface import Model, ModelTracing +from openai.types.responses import ResponseFunctionToolCall, ResponseOutputMessage, ResponseOutputText +from openai.types.responses.response_prompt_param import ResponsePromptParam + +EXECUTE = "submilli__typescript__execute" + +_ids = itertools.count() + +Call = tuple[str, dict[str, Any], str] + + +def call(name: str, args: dict[str, Any], id: str | None = None) -> Call: + return (name, args, id or f"call-{next(_ids)}") + + +def execute(code: str, id: str | None = None) -> Call: + return call(EXECUTE, {"code": code}, id) + + +class Scripted(Model): + """Answers each model call with the next turn: tool calls, or a final text. Records what it saw.""" + + def __init__(self, *turns: list[Call] | str) -> None: + self.turns = list(turns) + self.seen: list[list[Any]] = [] + self.tools: list[list[Tool]] = [] + + async def get_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], + model_settings: ModelSettings, + tools: list[Tool], + output_schema: AgentOutputSchemaBase | None, + handoffs: list[Handoff[Any, Any]], + tracing: ModelTracing, + *, + previous_response_id: str | None, + conversation_id: str | None, + prompt: ResponsePromptParam | None, + ) -> ModelResponse: + self.seen.append([input] if isinstance(input, str) else list(input)) + self.tools.append(list(tools)) + turn = self.turns.pop(0) + if isinstance(turn, str): + text = ResponseOutputText(type="output_text", text=turn, annotations=[]) + output: list[Any] = [ + ResponseOutputMessage( + id="message", type="message", role="assistant", status="completed", content=[text] + ) + ] + else: + output = [ + ResponseFunctionToolCall(type="function_call", call_id=id, name=name, arguments=json.dumps(args)) + for name, args, id in turn + ] + return ModelResponse(output=output, usage=Usage(), response_id=None) + + def stream_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], + model_settings: ModelSettings, + tools: list[Tool], + output_schema: AgentOutputSchemaBase | None, + handoffs: list[Handoff[Any, Any]], + tracing: ModelTracing, + *, + previous_response_id: str | None, + conversation_id: str | None, + prompt: ResponsePromptParam | None, + ) -> AsyncIterator[TResponseStreamEvent]: + raise NotImplementedError + + def descriptions(self, turn: int = 0) -> dict[str, str]: + """The function tools the model was given on `turn`, by name, with their descriptions.""" + return {tool.name: tool.description for tool in self.tools[turn] if isinstance(tool, FunctionTool)} + + def tool_outputs(self) -> list[Any]: + """The tool outputs in the last input the model saw, parsed as JSON where they are JSON.""" + outputs = [] + for item in self.seen[-1]: + if isinstance(item, dict) and item.get("type") == "function_call_output": + try: + outputs.append(json.loads(item["output"])) + except ValueError: + outputs.append(item["output"]) + return outputs + + +def scripted(*turns: list[Call] | str) -> Scripted: + return Scripted(*turns) + + +def calls_then_done(*calls: Call) -> Scripted: + """One turn with `calls` in parallel, then "done".""" + return Scripted(list(calls), "done") diff --git a/packages/openai-agents-submilli/tests/unit_tests/__init__.py b/packages/openai-agents-submilli/tests/unit_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/openai-agents-submilli/tests/unit_tests/fake_server.py b/packages/openai-agents-submilli/tests/unit_tests/fake_server.py new file mode 100644 index 0000000..2c5e73a --- /dev/null +++ b/packages/openai-agents-submilli/tests/unit_tests/fake_server.py @@ -0,0 +1,284 @@ +"""A stateful stand-in for submilli-server's HTTP API, served through respx.""" + +from __future__ import annotations + +import json +import re +import uuid +from dataclasses import dataclass, field +from typing import Any + +import httpx +import respx + +SERVER = "http://submilli.test" +TOKEN = "test-token" + +PROMPT = { + "name": "research", + "prompt": "Run a submilli program — strict TypeScript subset compiled to WebAssembly.", + "tools": { + "search": "Search packages.", + "docs": "Package docs.", + "builtins": "Built-ins.", + "builtins_list": "List built-ins.", + "builtins_docs": "Built-in docs.", + "last_run": "The last run.", + }, +} + + +@dataclass +class Session: + blueprint: str + variables: dict[str, str] + secrets: dict[str, str] + runs: int = 0 + last: dict[str, Any] | None = None + # (key) -> (code, body), or (code, run id) for a run that may suspend + ledger: dict[str, tuple[str, Any]] = field(default_factory=dict) + tools: list[dict[str, Any]] = field(default_factory=list) + + +@dataclass +class Run: + """A program that calls `@tools`: one suspension per call, in order.""" + + id: str + session_id: str + calls: list[tuple[str, dict[str, Any]]] + answers: list[dict[str, Any]] = field(default_factory=list) + finished: dict[str, Any] | None = None + + +@dataclass +class FakeServer: + """Behaves as the HTTP API reference says, for the parts the package uses. + + Programs are not run: `outcome` decides what an execute returns, and + `refuse_next` makes the next execute answer one of the refusals. + """ + + sessions: dict[str, Session] = field(default_factory=dict) + requests: list[httpx.Request] = field(default_factory=list) + required_secrets: list[str] = field(default_factory=list) + lost_secrets: set[str] = field(default_factory=set) + refuse_next: list[tuple[int, dict[str, Any]]] = field(default_factory=list) + outcome: dict[str, Any] = field(default_factory=lambda: {"result": "ok", "console": [], "error": None}) + prompt_fetches: int = 0 + # Code -> the `@tools` calls it makes, (tool, args) in order. Programs in a + # session with tools whose code is listed here suspend at each call, and + # return the answers they got as JSON. + programs: dict[str, list[tuple[str, dict[str, Any]]]] = field(default_factory=dict) + suspended: dict[str, Run] = field(default_factory=dict) + open_warnings: list[str] = field(default_factory=list) + # As a server with `durable_execution` answers: the next executes answer + # these run views, and `GET /v1/runs/{id}` answers `views[id]`, in turn. + durable_next: list[dict[str, Any]] = field(default_factory=list) + views: dict[str, list[dict[str, Any]]] = field(default_factory=dict) + + def install(self, router: respx.MockRouter) -> None: + router.route(host="submilli.test").mock(side_effect=self.handle) + + def handle(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + if request.headers.get("authorization") != f"Bearer {TOKEN}": + return _json(401, {"error": "unauthorized", "message": "missing or unrecognised API token"}) + path, method = request.url.path, request.method + body: dict[str, Any] = json.loads(request.content) if request.content else {} + if method == "POST" and path == "/v1/sessions": + return self.open(body) + if match := re.fullmatch(r"/v1/sessions/([^/]+)/execute", path): + return self.execute(match[1], body, request.headers.get("idempotency-key")) + if match := re.fullmatch(r"/v1/sessions/([^/]+)/rebind", path): + return self.rebind(match[1], body) + if match := re.fullmatch(r"/v1/sessions/([^/]+)/packages/(search|docs)", path): + return self.session_discovery(match[1], match[2], request) + if match := re.fullmatch(r"/v1/runs/([^/]+)/resume", path): + return self.resume(match[1], body) + if match := re.fullmatch(r"/v1/runs/([^/]+)", path): + if self.views.get(match[1]): + return _json(200, self.views[match[1]].pop(0)) + run = self.suspended.get(match[1]) + if run is None: + return _json(404, {"error": "unknown_run", "execution_id": match[1], "message": "no such run"}) + return _json(200, self.state(run)) + if match := re.fullmatch(r"/v1/sessions/([^/]+)/last-run", path): + session = self.sessions.get(match[1]) + if session is None or session.last is None: + return httpx.Response(404) + return _json(200, session.last) + if method == "DELETE" and (match := re.fullmatch(r"/v1/sessions/([^/]+)", path)): + return httpx.Response(204 if self.sessions.pop(match[1], None) else 404) + if match := re.fullmatch(r"/v1/blueprints/([^/]+)/(.*)", path): + if match[1] != "research": + return _json(404, {"error": "not_found", "message": f"blueprint '{match[1]}' is not registered"}) + return self.discovery(match[2], request) + return httpx.Response(500, text=f"unexpected {method} {path}") + + def open(self, body: dict[str, Any]) -> httpx.Response: + if body["blueprint"] != "research": + return _json(404, {"error": "unknown blueprint", "message": f"unknown blueprint: {body['blueprint']}"}) + variables = body.get("variables") or {} + if "userId" not in variables: + return _json(400, {"error": "invalid variables: required variable 'userId' was not supplied"}) + secrets = body.get("secrets") or {} + for name in self.required_secrets: + if not secrets.get(name): + return _json(400, {"error": f"invalid secrets: required harness secret '{name}' is missing or empty"}) + session_id = str(uuid.uuid4()) + self.sessions[session_id] = Session(body["blueprint"], variables, secrets, tools=body.get("tools") or []) + opened: dict[str, Any] = {"session_id": session_id} + if body.get("tools"): + opened["warnings"] = list(self.open_warnings) + return _json(200, opened) + + def execute(self, session_id: str, body: dict[str, Any], key: str | None) -> httpx.Response: + session = self.sessions.get(session_id) + if session is None: + return _json(404, {"error": "unknown session", "session_id": session_id}) + if session_id in self.lost_secrets: + return _json( + 409, {"error": "session_requires_secrets", "required": self.required_secrets, "session_id": session_id} + ) + if self.refuse_next: + status, refusal = self.refuse_next.pop(0) + return _json(status, {**refusal, "session_id": session_id}) + if self.durable_next: + return _json(200, {**self.durable_next.pop(0), "session_id": session_id}) + code = body["code"] + if key is not None and key in session.ledger: + recorded_code, recorded = session.ledger[key] + if recorded_code != code: + return _json( + 409, {"error": "idempotency_conflict", "detail": "different code", "session_id": session_id} + ) + if isinstance(recorded, str): + run = self.suspended.get(recorded) + if run is None: + return _json(409, {"error": "idempotency_incomplete", "session_id": session_id}) + return _json(200, self.state(run)) + return _json(200, recorded) + if session.tools and code in self.programs: + session.runs += 1 + run = Run(str(uuid.uuid4()), session_id, list(self.programs[code])) + self.suspended[run.id] = run + if key is not None: + session.ledger[key] = (code, run.id) + return _json(200, self.state(run)) + session.runs += 1 + result = {"execution_id": str(uuid.uuid4()), "session_id": session_id, **self.outcome} + if key is not None: + session.ledger[key] = (code, result) + session.last = {key: result[key] for key in ("result", "console", "error")} + return _json(200, result) + + def state(self, run: Run) -> dict[str, Any]: + if run.finished is not None: + return run.finished + position = len(run.answers) + if position < len(run.calls): + tool, args = run.calls[position] + call = {"call_id": f"{run.id}:{position + 1}", "tool": tool, "args": args} + return {"status": "suspended", "execution_id": run.id, "session_id": run.session_id, "calls": [call]} + error = None + for answer in run.answers: + if not answer["ok"] and error is None: + kind = answer["error"]["kind"] + message = answer["error"]["message"] + if kind == "denied": + error = { + "kind": "permission_denied", + "message": f"PermissionDeniedError: @tools: {message}", + "caller": "main", + "capability": "tools.call", + "source": "harness", + } + else: + error = {"kind": "runtime_error", "message": f"Error: {message}"} + values = [answer.get("value") for answer in run.answers] + run.finished = { + "status": "finished", + "execution_id": run.id, + "session_id": run.session_id, + "result": None if error else json.dumps(values), + "console": [], + "error": error, + } + self.sessions[run.session_id].last = {key: run.finished[key] for key in ("result", "console", "error")} + return run.finished + + def resume(self, run_id: str, body: dict[str, Any]) -> httpx.Response: + run = self.suspended.get(run_id) + if run is None: + return _json(404, {"error": "unknown_run", "execution_id": run_id, "message": "no such run"}) + for result in body["results"]: + sequence = int(result["call_id"].rsplit(":", 1)[1]) + if sequence <= len(run.answers): + continue # answered already: changes nothing + if run.finished is not None: + return _json(409, {"error": "run_not_suspended", "execution_id": run_id, "message": "finished"}) + if sequence != len(run.answers) + 1: + return _json(400, {"error": "unknown_call", "execution_id": run_id, "message": "another call"}) + run.answers.append(result) + return _json(200, self.state(run)) + + def session_discovery(self, session_id: str, rest: str, request: httpx.Request) -> httpx.Response: + session = self.sessions.get(session_id) + if session is None: + return _json(404, {"error": "unknown session", "session_id": session_id}) + names = [tool["name"] for tool in session.tools] + if rest == "search": + results = [{"name": "@submilli/jina", "description": request.url.params.get("q"), "source": "registry"}] + if session.tools: + results.append({"name": "@tools", "description": "The application's tools.", "source": "tools"}) + return _json(200, {"results": results}) + if request.url.params["name"] == "@tools": + if not session.tools: + return _json(404, {"error": "unknown_package", "message": "unknown package: @tools"}) + return httpx.Response( + 200, text="# @tools\n" + "\n".join(f"function {name}(args): unknown;" for name in names) + ) + return self.discovery("packages/docs", request) + + def rebind(self, session_id: str, body: dict[str, Any]) -> httpx.Response: + if session_id not in self.sessions: + return _json(404, {"error": "unknown session", "session_id": session_id}) + for name in self.required_secrets: + if not body["secrets"].get(name): + return _json(400, {"error": f"invalid secrets: required harness secret '{name}' is missing or empty"}) + self.sessions[session_id].secrets = body["secrets"] + self.lost_secrets.discard(session_id) + return httpx.Response(204) + + def discovery(self, rest: str, request: httpx.Request) -> httpx.Response: + params = request.url.params + if rest == "prompt": + self.prompt_fetches += 1 + return _json(200, PROMPT) + if rest == "packages/search": + return _json( + 200, {"results": [{"name": "@submilli/jina", "description": params.get("q"), "source": "registry"}]} + ) + if rest == "packages/docs": + name = params["name"] + if name == "@submilli/jina": + return httpx.Response(200, text="# jina\nfunction searchJson(): string;") + return _json(404, {"error": "unknown_package", "message": f"unknown package: {name}"}) + if rest == "builtins": + return _json(200, {"namespaces": ["JSON"], "types": ["Map"]}) + if rest == "builtins/docs": + return _json(200, {"results": [{"name": name, "declarations": "..."} for name in params.get_list("name")]}) + return httpx.Response(404) + + # Helpers for assertions + + def calls(self, method: str, pattern: str) -> list[httpx.Request]: + return [r for r in self.requests if r.method == method and re.fullmatch(pattern, r.url.path)] + + def total_runs(self) -> int: + return sum(session.runs for session in self.sessions.values()) + + +def _json(status: int, body: Any) -> httpx.Response: + return httpx.Response(status, json=body) diff --git a/packages/openai-agents-submilli/tests/unit_tests/test_app_tools.py b/packages/openai-agents-submilli/tests/unit_tests/test_app_tools.py new file mode 100644 index 0000000..31156ec --- /dev/null +++ b/packages/openai-agents-submilli/tests/unit_tests/test_app_tools.py @@ -0,0 +1,270 @@ +"""Programs calling the application's own tools (`@tools`), against a fake of the HTTP API.""" + +from __future__ import annotations + +import json +from collections.abc import Iterator +from typing import Any + +import pytest +import respx +from agents import Agent, RunContextWrapper, Runner, RunResult, RunState, function_tool, set_tracing_disabled +from agents.items import ToolCallOutputItem +from agents.testing.model import ScriptedModel, assistant_message, function_call +from pydantic import BaseModel +from typing_extensions import TypedDict + +from openai_agents_submilli import SubmilliTools +from openai_agents_submilli._app_tools import declaration +from tests.scripted import Scripted, call, calls_then_done, execute +from tests.unit_tests.fake_server import SERVER, TOKEN, FakeServer + +set_tracing_disabled(True) + +ADA = {"user_id": "u_ada", "conversation_id": "c_1"} + + +class Order(TypedDict): + id: str + total: float + + +class Customer(BaseModel): + name: str + orders: list[Order] + + +@function_tool +def lookup_order(order_id: str) -> Order: + """Look up an order.""" + return {"id": order_id, "total": 12.5} + + +@function_tool +def customer(customer_id: str) -> Customer: + """Look up a customer.""" + return Customer(name="Ada", orders=[{"id": "o_1", "total": 3.0}]) + + +@function_tool +def greet(name: str) -> str: + """Greet someone.""" + return f"hello {name}" + + +@function_tool +def broken(order_id: str) -> str: + """Always fails.""" + raise ValueError(f"no order {order_id}") + + +@function_tool +def whoami(wrapper: RunContextWrapper[Any]) -> str: + """The run's user.""" + return str(wrapper.context["user_id"]) + + +@function_tool(needs_approval=True) +def refund(order_id: str, amount: float) -> str: + """Refund an order.""" + return f"refunded {amount} on {order_id}" + + +@pytest.fixture +def server() -> Iterator[FakeServer]: + fake = FakeServer() + with respx.mock(assert_all_called=False) as router: + fake.install(router) + yield fake + + +def toolkit(*ptc: Any) -> SubmilliTools: + return SubmilliTools(blueprint="research", server=SERVER, token=TOKEN, ptc=list(ptc)) + + +def agent(submilli: SubmilliTools, model: Scripted) -> Agent[Any]: + return Agent(name="assistant", model=model, tools=list(submilli.tools)) + + +def execute_output(result: RunResult) -> ToolCallOutputItem: + [output] = [item for item in result.new_items if isinstance(item, ToolCallOutputItem)] + return output + + +def test_declarations_carry_input_and_output_schemas() -> None: + order = declaration(lookup_order) + assert order["name"] == "lookup_order" + assert order.get("description") == "Look up an order." + assert order["input_schema"]["properties"] == {"order_id": {"title": "Order Id", "type": "string"}} + assert order.get("output_schema", {})["properties"]["total"] == {"title": "Total", "type": "number"} + nested = declaration(customer).get("output_schema", {}) + assert "$defs" not in json.dumps(nested) + assert nested["properties"]["orders"]["items"]["properties"]["id"] == {"title": "Id", "type": "string"} + assert declaration(greet).get("output_schema") == {"type": "string"} + + +def test_ptc_rejects_names_programs_cannot_call() -> None: + @function_tool(name_override="not-an-identifier") + def dashed() -> str: + return "" + + with pytest.raises(ValueError, match="identifier"): + toolkit(dashed) + with pytest.raises(ValueError, match="repeated: greet"): + toolkit(greet, greet) + with pytest.raises(TypeError, match="not tool names"): + toolkit("greet") + + +async def test_a_program_calls_the_applications_tools(server: FakeServer) -> None: + server.programs["main()"] = [("lookup_order", {"order_id": "o_7"}), ("customer", {"customer_id": "c_1"})] + submilli = toolkit(lookup_order, customer) + model = calls_then_done(execute("main()")) + result = await Runner.run(agent(submilli, model), "go", context=ADA) + output = execute_output(result) + reported = json.loads(output.output) + assert json.loads(reported["result"]) == [ + {"id": "o_7", "total": 12.5}, + {"name": "Ada", "orders": [{"id": "o_1", "total": 3.0}]}, + ] + assert output.custom_data is not None + assert [(c["tool"], c["outcome"]) for c in output.custom_data["tool_calls"]] == [ + ("lookup_order", "ok"), + ("customer", "ok"), + ] + [session] = server.sessions.values() + assert [tool["name"] for tool in session.tools] == ["lookup_order", "customer"] + # The model sees the tools in the execute description, not as tools of its own. + described = model.descriptions() + assert "`lookup_order`, `customer`" in described["submilli__typescript__execute"] + assert "lookup_order" not in described + + +async def test_a_tool_that_raises_fails_the_programs_call(server: FakeServer) -> None: + server.programs["main()"] = [("broken", {"order_id": "o_7"})] + result = await Runner.run(agent(toolkit(broken), calls_then_done(execute("main()"))), "go", context=ADA) + output = execute_output(result) + reported = json.loads(output.output) + assert reported["error"]["kind"] == "runtime_error" + assert "ValueError: no order o_7" in reported["error"]["message"] + assert output.custom_data is not None and output.custom_data["tool_calls"][0]["outcome"] == "failed" + + +async def test_the_tool_gets_the_runs_context(server: FakeServer) -> None: + server.programs["main()"] = [("whoami", {})] + result = await Runner.run(agent(toolkit(whoami), calls_then_done(execute("main()"))), "go", context=ADA) + assert json.loads(json.loads(execute_output(result).output)["result"]) == ["u_ada"] + + +async def test_an_approval_interrupts_the_run_with_the_applications_tool(server: FakeServer) -> None: + server.programs["main()"] = [("refund", {"order_id": "o_7", "amount": 18})] + submilli = toolkit(refund) + assistant = agent(submilli, calls_then_done(execute("main()"))) + result = await Runner.run(assistant, "go", context=ADA) + [approval] = result.interruptions + assert approval.tool_name == "refund" + assert json.loads(approval.arguments or "{}") == {"order_id": "o_7", "amount": 18} + state = result.to_state() + state.approve(approval) + result = await Runner.run(assistant, state) + assert result.interruptions == [] + assert json.loads(json.loads(execute_output(result).output)["result"]) == ["refunded 18.0 on o_7"] + assert server.total_runs() == 1 + + +async def test_a_rejection_is_a_denial_in_the_program(server: FakeServer) -> None: + server.programs["main()"] = [("refund", {"order_id": "o_7", "amount": 42})] + assistant = agent(toolkit(refund), calls_then_done(execute("main()"))) + result = await Runner.run(assistant, "go", context=ADA) + state = result.to_state() + state.reject(result.interruptions[0], rejection_message="Refunds over $25 need a manager.") + result = await Runner.run(assistant, state) + output = execute_output(result) + reported = json.loads(output.output) + assert reported["denied"] is True + assert reported["error"]["source"] == "harness" + assert "need a manager" in reported["error"]["message"] + assert output.custom_data is not None and output.custom_data["tool_calls"][0]["outcome"] == "denied" + # The model sees the calls too, without the ids that are only for the audit trail. + assert reported["tool_calls"] == [ + {"tool": "refund", "args": {"order_id": "o_7", "amount": 42}, "outcome": "denied"} + ] + + +async def test_two_approvals_resume_from_a_serialized_state(server: FakeServer) -> None: + server.programs["main()"] = [ + ("refund", {"order_id": "o_1", "amount": 5}), + ("lookup_order", {"order_id": "o_2"}), + ("refund", {"order_id": "o_2", "amount": 7}), + ] + submilli = toolkit(refund, lookup_order) + assistant = agent(submilli, calls_then_done(execute("main()"))) + result = await Runner.run(assistant, "go", context=ADA) + approved = [] + while result.interruptions: + # As if in another process: only the serialized state and the store carry over. + state = await RunState.from_string(assistant, result.to_state().to_string()) + for item in state.get_interruptions(): + approved.append(json.loads(item.arguments or "{}")["order_id"]) + state.approve(item) + result = await Runner.run(assistant, state) + assert approved == ["o_1", "o_2"] + values = json.loads(json.loads(execute_output(result).output)["result"]) + assert values == ["refunded 5.0 on o_1", {"id": "o_2", "total": 12.5}, "refunded 7.0 on o_2"] + assert server.total_runs() == 1 + + +async def test_discovery_includes_the_sessions_tools(server: FakeServer) -> None: + model = calls_then_done( + call("submilli__typescript__packages__search", {"query": ""}), + call("submilli__typescript__packages__docs", {"name": "@tools"}), + ) + result = await Runner.run(agent(toolkit(greet), model), "go", context=ADA) + texts = [item.output for item in result.new_items if isinstance(item, ToolCallOutputItem)] + assert "@tools" in [entry["name"] for entry in json.loads(texts[0])["results"]] + assert "function greet" in texts[1] + assert len(server.sessions) == 1 + + +async def test_a_session_opened_with_other_tools_is_not_reused(server: FakeServer) -> None: + first = toolkit(greet) + await Runner.run(agent(first, calls_then_done(execute("a"))), "go", context=ADA) + second = SubmilliTools( + blueprint="research", server=SERVER, token=TOKEN, ptc=[greet, lookup_order], store=first.store + ) + await Runner.run(agent(second, calls_then_done(execute("b"))), "go", context=ADA) + assert len(server.sessions) == 2 + + +async def test_plain_functions_are_wrapped(server: FakeServer) -> None: + def double(n: int) -> int: + """Double a number.""" + return n * 2 + + server.programs["main()"] = [("double", {"n": 21})] + result = await Runner.run(agent(toolkit(double), calls_then_done(execute("main()"))), "go", context=ADA) + assert json.loads(json.loads(execute_output(result).output)["result"]) == [42] + + +async def test_approvals_work_in_a_streamed_run(server: FakeServer) -> None: + server.programs["main()"] = [("refund", {"order_id": "o_7", "amount": 18})] + model = ScriptedModel( + [ + [function_call("submilli__typescript__execute", {"code": "main()"}, call_id="call_1")], + [assistant_message("done")], + ] + ) + assistant = Agent(name="assistant", model=model, tools=list(toolkit(refund).tools)) + streamed = Runner.run_streamed(assistant, "go", context=ADA) + async for _ in streamed.stream_events(): + pass + [approval] = streamed.interruptions + assert approval.tool_name == "refund" + state = streamed.to_state() + state.approve(approval) + streamed = Runner.run_streamed(assistant, state) + async for _ in streamed.stream_events(): + pass + assert streamed.final_output == "done" + [output] = [item for item in streamed.new_items if isinstance(item, ToolCallOutputItem)] + assert json.loads(json.loads(output.output)["result"]) == ["refunded 18.0 on o_7"] diff --git a/packages/openai-agents-submilli/tests/unit_tests/test_toolkit.py b/packages/openai-agents-submilli/tests/unit_tests/test_toolkit.py new file mode 100644 index 0000000..ab519e1 --- /dev/null +++ b/packages/openai-agents-submilli/tests/unit_tests/test_toolkit.py @@ -0,0 +1,315 @@ +"""`SubmilliTools` against a fake of the HTTP API, with a scripted model.""" + +from __future__ import annotations + +import json +import re +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Any + +import pytest +import respx +from agents import Agent, Runner, RunResult, UserError, set_tracing_disabled +from agents.items import ToolCallOutputItem + +from openai_agents_submilli import ( + SQLiteSessionStore, + SubmilliConversationError, + SubmilliSessionMismatchError, + SubmilliTools, +) +from submilli import AuthenticationError, SubmilliError, UnknownBlueprintError +from tests.scripted import EXECUTE, Scripted, call, calls_then_done, execute, scripted +from tests.unit_tests.fake_server import SERVER, TOKEN, FakeServer + +set_tracing_disabled(True) + +ADA = {"user_id": "u_ada", "conversation_id": "c_1"} + + +@pytest.fixture +def server() -> Iterator[FakeServer]: + fake = FakeServer() + with respx.mock(assert_all_called=False) as router: + fake.install(router) + yield fake + + +def toolkit(**kwargs: Any) -> SubmilliTools: + return SubmilliTools(blueprint="research", server=SERVER, token=TOKEN, **kwargs) + + +async def run(submilli: SubmilliTools, model: Scripted, context: Any = None, *, question: str = "go") -> RunResult: + agent = Agent(name="assistant", model=model, tools=list(submilli.tools)) + return await Runner.run(agent, question, context=ADA if context is None else context) + + +def outputs(result: RunResult) -> list[ToolCallOutputItem]: + return [item for item in result.new_items if isinstance(item, ToolCallOutputItem)] + + +def content(item: ToolCallOutputItem) -> Any: + return json.loads(item.output) + + +@contextmanager +def raises_from_tool(error: type[Exception], match: str | None = None) -> Iterator[None]: + """The SDK raises a tool's exception as the cause of a `UserError`.""" + with pytest.raises(UserError) as raised: + yield + assert isinstance(raised.value.__cause__, error), repr(raised.value.__cause__) + if match is not None: + assert re.search(match, str(raised.value.__cause__)) + + +async def test_execute_opens_a_session_bound_to_the_user_and_sends_the_tool_call_id(server: FakeServer) -> None: + result = await run(toolkit(), calls_then_done(execute("main()", id="call_abc"))) + [output] = outputs(result) + assert content(output) == {"result": "ok", "console": [], "error": None, "denied": False} + [session] = server.sessions.values() + assert session.variables == {"userId": "u_ada"} + [request] = server.calls("POST", r"/v1/sessions/[^/]+/execute") + assert request.headers["idempotency-key"] == "call_abc" + assert output.custom_data is not None + assert output.custom_data["session_id"] in server.sessions + assert output.custom_data["execution_id"] + + +async def test_tools_carry_the_blueprints_descriptions_fetched_once(server: FakeServer) -> None: + submilli = toolkit() + assert [tool.name for tool in submilli.tools] == [ + EXECUTE, + "submilli__typescript__last_run", + "submilli__typescript__packages__search", + "submilli__typescript__packages__docs", + "submilli__typescript__builtins__list", + "submilli__typescript__builtins__docs", + ] + model = scripted("hi") + await run(submilli, model) + await run(submilli, scripted("again")) + described = model.descriptions() + assert described[EXECUTE].startswith("Run a submilli program") + assert described["submilli__typescript__last_run"] == "The last run." + assert server.prompt_fetches == 1 + + +async def test_a_conversation_keeps_its_session_across_turns(server: FakeServer) -> None: + submilli = toolkit() + await run(submilli, calls_then_done(execute("a"))) + await run(submilli, calls_then_done(execute("b"))) + assert len(server.sessions) == 1 + await run(submilli, calls_then_done(execute("c")), {"user_id": "u_ada", "conversation_id": "c_2"}) + assert len(server.sessions) == 2 + + +async def test_parallel_calls_share_one_session(server: FakeServer) -> None: + await run(toolkit(), calls_then_done(execute("a"), execute("b"), execute("c"))) + assert len(server.sessions) == 1 + + +async def test_different_variables_in_a_conversation_are_refused_before_the_model_runs(server: FakeServer) -> None: + submilli = toolkit() + await run(submilli, calls_then_done(execute("a"))) + model = scripted("never") + with pytest.raises(SubmilliSessionMismatchError, match="differing: userId"): + await run(submilli, model, {"user_id": "u_grace", "conversation_id": "c_1"}) + assert model.seen == [] + + +async def test_a_context_without_a_conversation_fails_clearly(server: FakeServer) -> None: + with raises_from_tool(SubmilliConversationError, match="conversation_id"): + await run(toolkit(), calls_then_done(execute("a")), {"user_id": "u_ada"}) + + +async def test_a_custom_conversation_id(server: FakeServer) -> None: + @dataclass + class Context: + user_id: str + thread: str + + submilli = toolkit(conversation_id=lambda context: context.thread) + await run(submilli, calls_then_done(execute("a")), Context("u_ada", "t_1")) + await run(submilli, calls_then_done(execute("b")), Context("u_ada", "t_1")) + assert len(server.sessions) == 1 + assert await submilli.store.get("t_1") is not None + + +async def test_discovery_tools_do_not_open_a_session(server: FakeServer) -> None: + model = calls_then_done( + call("submilli__typescript__packages__search", {"query": "jina"}), + call("submilli__typescript__packages__docs", {"name": "@submilli/jina"}), + call("submilli__typescript__builtins__list", {}), + call("submilli__typescript__builtins__docs", {"names": ["Map"]}), + call("submilli__typescript__last_run", {}), + ) + result = await run(toolkit(), model) + texts = [item.output for item in outputs(result)] + assert json.loads(texts[0])["results"][0]["name"] == "@submilli/jina" + assert texts[1].startswith("# jina") + assert texts[4] == "No program has run in this conversation yet." + assert server.sessions == {} + + +async def test_last_run_reads_the_conversations_session(server: FakeServer) -> None: + submilli = toolkit() + await run(submilli, calls_then_done(execute("a"))) + result = await run(submilli, calls_then_done(call("submilli__typescript__last_run", {}))) + assert json.loads(outputs(result)[0].output)["result"] == "ok" + + +async def test_permission_denied_is_marked_for_the_model(server: FakeServer) -> None: + server.outcome = { + "result": None, + "console": [], + "error": {"kind": "permission_denied", "message": "denied", "capability": "net.fetch", "source": "policy"}, + } + result = await run(toolkit(), calls_then_done(execute("a"))) + reported = content(outputs(result)[0]) + assert reported["denied"] is True + assert "final" in reported["note"] + + +async def test_an_ended_session_is_replaced_and_the_model_told(server: FakeServer) -> None: + submilli = toolkit() + await run(submilli, calls_then_done(execute("a"))) + first = await submilli.store.get("c_1") + assert first is not None + server.sessions.clear() + result = await run(submilli, calls_then_done(execute("b"))) + assert "new session" in content(outputs(result)[0])["notice"] + second = await submilli.store.get("c_1") + assert second is not None and second["id"] != first["id"] + assert list(server.sessions) == [second["id"]] + + +async def test_lost_secrets_are_sent_again_with_rebind(server: FakeServer) -> None: + server.required_secrets = ["apiKey"] + submilli = toolkit(secrets=lambda context: {"apiKey": "k-" + context["user_id"]}) + await run(submilli, calls_then_done(execute("a"))) + [session_id] = server.sessions + server.lost_secrets.add(session_id) + result = await run(submilli, calls_then_done(execute("b"))) + assert content(outputs(result)[0])["result"] == "ok" + assert server.sessions[session_id].secrets == {"apiKey": "k-u_ada"} + stored = await submilli.store.get("c_1") + assert stored is not None and "apiKey" not in json.dumps(stored) + + +@pytest.mark.parametrize( + ("status", "code", "phrase"), + [ + (409, "idempotency_conflict", "different code"), + (409, "idempotency_incomplete", "side effects are unknown"), + (409, "idempotency_in_progress", "still running"), + (503, "idempotency_unavailable", "risk running it twice"), + ], +) +async def test_idempotency_refusals_are_explained(server: FakeServer, status: int, code: str, phrase: str) -> None: + server.refuse_next = [(status, {"error": code, "detail": "the detail"})] + result = await run(toolkit(), calls_then_done(execute("a"))) + [output] = outputs(result) + reported = content(output) + assert reported["error"]["kind"] == code + assert phrase in reported["error"]["message"] + assert output.custom_data is not None and output.custom_data["refusal"] == code + + +async def test_a_durable_run_without_a_result_is_reported_not_raised(server: FakeServer) -> None: + server.durable_next = [{"status": "stuck", "execution_id": "run-1", "reason": "worker crashed"}] + result = await run(toolkit(), calls_then_done(execute("a"))) + [output] = outputs(result) + reported = content(output) + assert reported["error"]["kind"] == "run_stuck" + assert reported["error"]["reason"] == "worker crashed" + assert output.custom_data is not None and output.custom_data["run_status"] == "stuck" + + +async def test_the_same_tool_call_replays(server: FakeServer) -> None: + submilli = toolkit() + await run(submilli, calls_then_done(execute("a", id="call_same"))) + await run(submilli, calls_then_done(execute("a", id="call_same"))) + assert server.total_runs() == 1 + + +async def test_a_long_tool_call_id_is_hashed(server: FakeServer) -> None: + await run(toolkit(), calls_then_done(execute("a", id="call_" + "x" * 200))) + [request] = server.calls("POST", r"/v1/sessions/[^/]+/execute") + assert len(request.headers["idempotency-key"]) == 64 + + +async def test_a_dataclass_context_works(server: FakeServer) -> None: + @dataclass + class Context: + user_id: str + conversation_id: str + + await run(toolkit(), calls_then_done(execute("a")), Context("u_ada", "c_9")) + [session] = server.sessions.values() + assert session.variables == {"userId": "u_ada"} + + +async def test_custom_variables(server: FakeServer) -> None: + await run(toolkit(variables={"userId": "fixed"}), calls_then_done(execute("a"))) + [session] = server.sessions.values() + assert session.variables == {"userId": "fixed"} + + +async def test_variables_must_be_strings(server: FakeServer) -> None: + with raises_from_tool(TypeError, match="must be strings"): + await run(toolkit(variables=lambda context: {"userId": 7}), calls_then_done(execute("a"))) + + +async def test_aclose_conversation_ends_the_session(server: FakeServer) -> None: + submilli = toolkit() + await run(submilli, calls_then_done(execute("a"))) + assert await submilli.aclose_conversation("c_1") is True + assert server.sessions == {} + assert await submilli.store.get("c_1") is None + assert await submilli.aclose_conversation("c_1") is False + await run(submilli, calls_then_done(execute("b")), {"user_id": "u_grace", "conversation_id": "c_1"}) + [session] = server.sessions.values() + assert session.variables == {"userId": "u_grace"} + + +async def test_a_session_for_another_blueprint_or_server_is_not_reused(server: FakeServer) -> None: + submilli = toolkit() + await submilli.store.set( + "c_1", {"id": "elsewhere", "server": "http://other", "blueprint": "research", "variables": {"userId": "u_ada"}} + ) + await run(submilli, calls_then_done(execute("a"))) + stored = await submilli.store.get("c_1") + assert stored is not None and stored["id"] in server.sessions + + +async def test_sessions_can_live_in_sqlite(server: FakeServer, tmp_path: Any) -> None: + path = tmp_path / "conversations.db" + await run(toolkit(store=SQLiteSessionStore(path)), calls_then_done(execute("a"))) + # Another process: the same database, a new toolkit. + await run(toolkit(store=SQLiteSessionStore(path)), calls_then_done(execute("b"))) + assert len(server.sessions) == 1 + + +async def test_server_and_token_come_from_the_environment(server: FakeServer, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SUBMILLI_SERVER", SERVER) + monkeypatch.setenv("SUBMILLI_SERVER_TOKEN", TOKEN) + await run(SubmilliTools(blueprint="research"), calls_then_done(execute("a"))) + assert len(server.sessions) == 1 + + +async def test_application_errors_raise(server: FakeServer) -> None: + with pytest.raises(AuthenticationError): + await run(SubmilliTools(blueprint="research", server=SERVER, token="wrong"), scripted("hi")) + # Found when the descriptions are fetched, before the model runs. + with pytest.raises(UnknownBlueprintError): + await run(SubmilliTools(blueprint="nope", server=SERVER, token=TOKEN), calls_then_done(execute("a"))) + with raises_from_tool(SubmilliError, match="userId"): + await run(toolkit(), calls_then_done(execute("a")), {"conversation_id": "c_1"}) + + +def test_run_sync_works(server: FakeServer) -> None: + agent = Agent(name="assistant", model=calls_then_done(execute("a")), tools=list(toolkit().tools)) + result = Runner.run_sync(agent, "go", context=ADA) + assert content(outputs(result)[0])["result"] == "ok" diff --git a/pyproject.toml b/pyproject.toml index 6e0e380..7e98f8a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,14 +15,14 @@ dev = [ [tool.ruff] line-length = 120 target-version = "py311" -src = ["packages/submilli/src", "packages/langchain-submilli/src"] +src = ["packages/submilli/src", "packages/langchain-submilli/src", "packages/openai-agents-submilli/src"] [tool.ruff.lint] select = ["E", "F", "I", "UP", "B", "SIM", "RUF", "D2", "D4"] ignore = ["D203", "D213", "D401", "D413"] [tool.ruff.lint.isort] -known-first-party = ["submilli", "langchain_submilli"] +known-first-party = ["submilli", "langchain_submilli", "openai_agents_submilli"] [tool.ruff.lint.per-file-ignores] "packages/*/tests/**" = ["D"] diff --git a/uv.lock b/uv.lock index 384eb2b..35f03a8 100644 --- a/uv.lock +++ b/uv.lock @@ -12,6 +12,7 @@ resolution-markers = [ [manifest] members = [ "langchain-submilli", + "openai-agents-submilli", "submilli", ] @@ -126,6 +127,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d6/85/c1c583ec96be412c4119a0ba4354c1e3519464036dd8d2909536661bed6a/ast_serialize-0.13.0-cp39-abi3-win_arm64.whl", hash = "sha256:8aff1682f9fa3e119a1cf8ef47504d38f7019b22b79e0f2b5c2135b9532d14db", size = 1138203, upload-time = "2026-10-12T17:06:20.466Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "bracex" version = "3.1" @@ -389,6 +399,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, ] +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -551,6 +570,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/28/faa7c02d87df1343aa8e51c816694a82eb37f41d0e83253e6e75a00b96ec/google_genai-2.30.1-py3-none-any.whl", hash = "sha256:1bb8961c60f7100439a45067a32764a18da0f8d0f95954ffc777d1f8225a2469", size = 1199070, upload-time = "2026-10-13T14:11:16.459Z" }, ] +[[package]] +name = "griffelib" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/e2/0f5d8ed08766bc6dcd3eebf5a6623d83cbca33a6e4c90aef57156ce745ad/griffelib-2.4.1.tar.gz", hash = "sha256:40c028dd2e623f464423f0e11e3c553158f1cf4f1368239a370534d73e1407ff", size = 245153, upload-time = "2026-10-22T11:20:03.705Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/02/1156d5f5307f573b901b14373848b84e69ca6cab09169b8cc97d16a181f9/griffelib-2.4.1-py3-none-any.whl", hash = "sha256:76bc562f936ad1ce198d2cf6d0731bd5c92a7d934b69ef828ef22ee0ffdb62ce", size = 172983, upload-time = "2026-10-22T11:19:59.843Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -779,6 +807,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/29/accef8eea16670b88f3a23102c35dee62c6f159db30d9628908043b3e21b/jsonpointer-3.2.1-py3-none-any.whl", hash = "sha256:b19ee68644e9ffb51440448d8f7811af2b7406eea1db90603e93f5849323119a", size = 8332, upload-time = "2026-10-10T21:11:05.648Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "langchain" version = "1.4.5" @@ -1142,6 +1197,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] +[[package]] +name = "mcp" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, + { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { 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/9d/8d/e0d339616f4810e9051d4aba6887afab289ab1f81875fe908b606cdfd0e3/mcp-2.3.0.tar.gz", hash = "sha256:8b147a50441cf059dc88c684e0aeed3687f0aa0f39c6cde7b90330effd2b34d8", size = 4171955, upload-time = "2026-10-02T22:06:56.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/d7/4a2878f128df729db3ca04e71bfce26405cc2e0c9ebe19c592feeb4c61b9/mcp-2.3.0-py3-none-any.whl", hash = "sha256:dd0c44c089d16453e8ae31a3877a0054d7a2314caaa81f5e0541b9b1734b2377", size = 368664, upload-time = "2026-10-02T22:06:52.119Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/2d/7c251e34207f6c51000312fc8839111ac45cfe02023f90b44e7f1051dd8e/mcp_types-2.3.0.tar.gz", hash = "sha256:d1e46549edb35ee19a94940fcee6d1addd7e589ab7ea92dda83f5d84781fc362", size = 65898, upload-time = "2026-10-02T22:06:57.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/48/32583066b155bb5d4507e7887dc88ee23118cc4de8e2a12870c49719e2a8/mcp_types-2.3.0-py3-none-any.whl", hash = "sha256:968efdbdaedfab06adae40d378a34395f1090c5921d4be3c9cde283aaf76d91d", size = 69108, upload-time = "2026-10-02T22:06:54.099Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -1390,6 +1483,87 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/7f/c2d1b436b6e7cfebac140c2579a298344b85f2991a2ce5c3615cefb29400/numpy-2.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:7a14a461d9340f1b46b8648578aed9cdb8b3b018a8fac6c1dde2c9192a01a87f", size = 10883718, upload-time = "2026-10-10T20:05:28.547Z" }, ] +[[package]] +name = "openai" +version = "3.39.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/25/c8242b66f7bc107d6a02887cb8463f45f54f77a150afb2613e61cb3e13f8/openai-3.39.0.tar.gz", hash = "sha256:02687994f7bb6a4e80ed7796426d46c654889821ce4eb48699be15b3a425e1d3", size = 2376122, upload-time = "2026-10-26T19:19:13.774Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/f9/32f6ceadc53d887894fc0ee7c76cce5567b972045a1c7f35b2a79f912735/openai-3.39.0-py3-none-any.whl", hash = "sha256:e29bc8d9785d9a386cffa0bed1726e7f3989e096245c0c003cb29f1d4ae67e32", size = 2352830, upload-time = "2026-10-26T19:19:10.874Z" }, +] + +[[package]] +name = "openai-agents" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffelib" }, + { name = "httpx2" }, + { name = "mcp" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "pyjwt" }, + { name = "python-multipart" }, + { name = "requests" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/36/537006bc2a22f3aa232ce0907f52a333f831a6a2f525894ff43747a7abee/openai_agents-0.24.0.tar.gz", hash = "sha256:4b4b285f4fed81fdf1b4ac426a9101658d9cd29b4fb1aaeee18f639b77e704e3", size = 7026950, upload-time = "2026-10-15T02:38:21.466Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/93/01efc034d7011b89381f96a9fb8957c09a16e3c190f3a5de128e1d0a42e9/openai_agents-0.24.0-py3-none-any.whl", hash = "sha256:18d4ac34e84ed937333ec8ce46395ed3521ffcbb0a521780a4c2eaa451467f49", size = 1242571, upload-time = "2026-10-15T02:38:18.835Z" }, +] + +[[package]] +name = "openai-agents-submilli" +version = "0.1.0" +source = { editable = "packages/openai-agents-submilli" } +dependencies = [ + { name = "openai-agents" }, + { name = "submilli" }, +] + +[package.dev-dependencies] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "respx" }, +] + +[package.metadata] +requires-dist = [ + { name = "openai-agents", specifier = ">=0.24.0,<0.25" }, + { name = "submilli", editable = "packages/submilli" }, +] + +[package.metadata.requires-dev] +test = [ + { name = "pytest", specifier = ">=8" }, + { name = "pytest-asyncio", specifier = ">=1" }, + { name = "respx", specifier = ">=0.22" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, +] + [[package]] name = "orjson" version = "3.13.0" @@ -1719,6 +1893,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] +[[package]] +name = "pyjwt" +version = "2.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pyright" version = "1.1.414" @@ -1833,6 +2021,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/ef/ab507f117b3d19b54e3c9c632a99c28c3b284562ec6e02e274581d530d92/pytest_socket-0.8.1-py3-none-any.whl", hash = "sha256:f9846bed1dcd96eed459e5e14795bbaf96715cf4e827891fe70773817ecb8ed4", size = 8751, upload-time = "2026-08-19T15:16:24.426Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1888,6 +2107,20 @@ wheels = [ { 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.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -1940,6 +2173,141 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] +[[package]] +name = "rpds-py" +version = "2026.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/42/68/3bd46b8a5e01d3c2ebdf9c5e9497912e3fe0cde02bac21a7130ca866e403/rpds_py-2026.9.1.tar.gz", hash = "sha256:4793ef7f78268b124b73fa933440f01d258bbae01de9fa53e9080c9ab0425a12", size = 63948, upload-time = "2026-10-04T16:32:36.469Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/6d/ddb8dfcf41892ba9c672d27988592c038d30db61708230313831b6780a1b/rpds_py-2026.9.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:2711d29b653b3bce48a63d18b9c6b53274669e6d6c4094dddeb4d9a0e45128b2", size = 344947, upload-time = "2026-10-04T16:28:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b2/632c3d034961149eff606e3e357547620a9ab37b849ae5815f67838753fd/rpds_py-2026.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3231c4c0e521dafa5be0c9f114ee2c2ad46650836f2d72caa86801950c3e7044", size = 346770, upload-time = "2026-10-04T16:28:56.884Z" }, + { url = "https://files.pythonhosted.org/packages/6d/45/4df346410bb873d03ec60c3b891d1fceb8cabde1971d29844bbb625cc296/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e01b3c878c8641913e688edd1b3f08658c6783d29cf6b826bd3c0d1ae7a1ffaa", size = 377795, upload-time = "2026-10-04T16:28:58.193Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b8/a70f302207e4f29f873c01a986fcac5def49b9c8224328cef1ad7da6b7a9/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3e524c7874ac72884d28e16dd5b8d839fd09e0fe76b020d3fbca23212a7b8c52", size = 383137, upload-time = "2026-10-04T16:28:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/13/e3/3aa28a48a01169d0e20728bf0e5ebbea3ae2774285b40853ae49b3ef332a/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761fdae6728ceb99ab182fad2f0cc1e262f610834dc891aea1d1a2a2e634776f", size = 496789, upload-time = "2026-10-04T16:29:01.011Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a7/414e8d088602d66ad6f47c65bf07fe5090992a8574cdd4571c1a83b647ed/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b723eb406dec5bc9ec516c73ab9c3239a3284e017f7eb89ee2b3258bd504fb7", size = 395929, upload-time = "2026-10-04T16:29:02.465Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5c/aac3b960fd55d37b49f77c0eebbe043eaf36762ebd48e7b46951e59c1c05/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:136a1c3fe4402b7008bc81cb62ee538481795b61a7e83df88dff3b3f02b726ff", size = 375426, upload-time = "2026-10-04T16:29:03.944Z" }, + { url = "https://files.pythonhosted.org/packages/d3/80/77987a41c40be7202a78d9aed4a650bfb05792237cfd3b9e22638d990fe5/rpds_py-2026.9.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:839dde845559254f34885267c6878f60d61d5205180226d976fe488d45fa128e", size = 381497, upload-time = "2026-10-04T16:29:05.308Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d0/640b02eb1ecd71a913c1178b6235287b31006266bca9a26862e80e497b54/rpds_py-2026.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:56cd8b3f77d7b6812f533b662186a1f28316931166ddc00fb893b1b0db7e9888", size = 405740, upload-time = "2026-10-04T16:29:06.578Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8c/d034195d45d215625a23f2ae85dbbdb86a49a93a51272061c97c80b9d316/rpds_py-2026.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:074a4d198bc34d9a8ea425114fc3ded6d11ec01f6a314a8db67454a5152d8834", size = 555850, upload-time = "2026-10-04T16:29:08.115Z" }, + { url = "https://files.pythonhosted.org/packages/de/f8/532f1addfc86207bc3cf23749638bb0d457bce891595012ed8a08d0543b8/rpds_py-2026.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6beb738155fe8ab8091afdfa5a3226b21c2b1593f1e50ebb90eb25b44dbc0391", size = 618661, upload-time = "2026-10-04T16:29:09.554Z" }, + { url = "https://files.pythonhosted.org/packages/50/5b/b0f2c9833a36a5b1f6ac5cb0e635fabacf713266f46ceefba2102da0bf51/rpds_py-2026.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:57492a550a1d88d29d003247e5f78dd8cf04a701fac0e4c8db8745a6d2504e0a", size = 583530, upload-time = "2026-10-04T16:29:11.072Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/464ed4b98d3c8c6a334f3b5e24d3886de04b05658a95fc40a37e0e6e4775/rpds_py-2026.9.1-cp311-cp311-win32.whl", hash = "sha256:d95a354e02393eada6d7351184671aced9d4cce109dabf927cb7aa99624352a1", size = 208125, upload-time = "2026-10-04T16:29:12.408Z" }, + { url = "https://files.pythonhosted.org/packages/df/c9/384a9b62f8cd89c8e3e609b8c9052c140e8c01d807261e0517c904b8bf29/rpds_py-2026.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:ce4d4f52e2a4324396caddbd45a97d8d7be5f42edd25d2355282a9c34f9b2f7f", size = 226045, upload-time = "2026-10-04T16:29:13.751Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3b/0a691f0dcb2301fa9c736346da2d0d4991778373283301c5c12b8f952f3d/rpds_py-2026.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:fdcd198979b4ecffcc1beba366a7fbcf4eb41243691a82fe52ceb0b902f09c12", size = 222636, upload-time = "2026-10-04T16:29:14.989Z" }, + { url = "https://files.pythonhosted.org/packages/5d/34/a828586ea3329fbb50895b50e9cf98ca3d924a9f41a0b26d443bff1b3794/rpds_py-2026.9.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:50906f5aea24b5a865cbd0a589698288631d9f3a54c3a937c83aefa95a0d14af", size = 346822, upload-time = "2026-10-04T16:29:16.258Z" }, + { url = "https://files.pythonhosted.org/packages/90/81/ac6a0d064982251856ce009c9e1dd51a34110b3c055aa7d1aad18b4899a3/rpds_py-2026.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e21c1429e205828ea886a2293a4a2c8e01f4c25d9893ca330e97a6cf73f52e7b", size = 341380, upload-time = "2026-10-04T16:29:17.706Z" }, + { url = "https://files.pythonhosted.org/packages/42/ff/bf7d54f362748fd6a49277b9d6531c394074110fedf50ad791ec59133fbb/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2693b2728bbcc48d09a981a356954b0c47c53ff25b545856f28a889ea619f69a", size = 373053, upload-time = "2026-10-04T16:29:19.063Z" }, + { url = "https://files.pythonhosted.org/packages/60/de/74b0ccdbbd28687b9b5fdb34c1cabed88352182facaced9d6f5486b8b9ed/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8601470267d938bcb7f3ab1a336100af51a4fd5b6ed030ef52461bb3ef5e7e07", size = 377742, upload-time = "2026-10-04T16:29:20.543Z" }, + { url = "https://files.pythonhosted.org/packages/c2/6d/b979775a3057b2a5c26d75ecbff60a20a24ef081db6dd76836fca3f20247/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3890a6aa36e6baa53d5258a2a25d3ef8b37ad165a6ab27a892d7c3e3a432cd69", size = 487289, upload-time = "2026-10-04T16:29:21.958Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5d/7c34734ce3ece943d9d6ee0122a74a21bbc75b47acb5b7053c83f6efb1ed/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b5b393eda5ea42cca1c1a6665f2a4882b4fd5d1777e41ce0545a107fb008c9d", size = 390710, upload-time = "2026-10-04T16:29:23.378Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6d/b26eb1e75395925b3a142ed351cbed2ec8212f5c9d937aee3df32c701baa/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:addeda51556dac7c1a2f14cda62db8b621cd12afba3091d03a96c72932387eab", size = 371261, upload-time = "2026-10-04T16:29:25.1Z" }, + { url = "https://files.pythonhosted.org/packages/7c/98/b2fdfe10301a9e27af21e634337fbba7baac0bc17fe44619a17c26bba2cb/rpds_py-2026.9.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d9edf30457d74eebfd76b045535e36f1cd89062566a128a0db2145ca042d787e", size = 376075, upload-time = "2026-10-04T16:29:26.748Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b1/c4b8d954e49c3c69cf8063c0c9e0919c99f3d2cecc46311606a1cbe835dc/rpds_py-2026.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:815d26356930846a40c7bc1366e7b1b0320ab8a063e66c11298a208bed0fd237", size = 399358, upload-time = "2026-10-04T16:29:28.282Z" }, + { url = "https://files.pythonhosted.org/packages/b3/59/9559a7293c97dff0cbb293efffbd36644103883ecc27741af8ef90c84ca8/rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b5a6f40f0a1486b4b36c888123afc67acdbd9f33235927acf5ff295429a0ba3", size = 550267, upload-time = "2026-10-04T16:29:29.851Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/6dc60e49511de4c8b00e74f9c1d43aed4d27f712cdb1ade92f2c199cbf64/rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b5b8b0753718d258fd454283fbd57e14545d3b40583fa672e27cb4f987626bcc", size = 614018, upload-time = "2026-10-04T16:29:31.54Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bc/92a4ecf27301b886232a413360f6b348a81d55ffef654f2ebda5970ebc3d/rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:46d80bc76b51a6c24f9944368c28d38b8bcbcea1da4f2f8d3ebc31a67e8c6ec6", size = 578509, upload-time = "2026-10-04T16:29:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/08/53/efb97f2e6589b7ab8394385a73870e0b2ab2a195281c117b21fd6d7d4855/rpds_py-2026.9.1-cp312-cp312-win32.whl", hash = "sha256:befc2d6a953e563f8a7bfd87a42c22ebf8a3e980dcb7b6a4d17b70b0e914e8a3", size = 205401, upload-time = "2026-10-04T16:29:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/1c/84/1700cc748d0eaa747486e4e82882d2575224e6e9a3148df583058850c629/rpds_py-2026.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:5ce8943f79c2210f7abcc28e86367b03b28d95027fd01c46d2472373ae70c86f", size = 222923, upload-time = "2026-10-04T16:29:35.709Z" }, + { url = "https://files.pythonhosted.org/packages/c3/89/0302215373c2f8b4408b0fdfee955368cc378a98ff59508d47a7e0dc2dbf/rpds_py-2026.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:501909f2e4a1e2dee528ef766fe3c469060ebc17e54a8383d404ba07a81a6f02", size = 218415, upload-time = "2026-10-04T16:29:37.263Z" }, + { url = "https://files.pythonhosted.org/packages/83/ea/ee88fd9e756ff93fb6b1182a47ec09504a242620e33ce1d20679efefe841/rpds_py-2026.9.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a36b70596407634ca82d4b989a3729074a008537a0522e4c8046a67c729103e9", size = 346229, upload-time = "2026-10-04T16:29:38.82Z" }, + { url = "https://files.pythonhosted.org/packages/57/71/a097d6552f837500fc36e6b23d09cfb9890c3cc47531f9ca64e149799615/rpds_py-2026.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eba5d173f7d5708b22a93815017a4611873ed54db9f268077c0dd1ed99cfc858", size = 340514, upload-time = "2026-10-04T16:29:40.405Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b7/497e85768bf4e0d8ddbaa096a4cac31d1509251dee2728a8490aa367e0b5/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:457866b85daf5034296666168b84a69e0b2e89dc4f1af102b46f6448a60b9063", size = 372611, upload-time = "2026-10-04T16:29:41.778Z" }, + { url = "https://files.pythonhosted.org/packages/52/4b/74ab4108916250b6e198e0d3af05bc6835eb046315f22f7a0ceb49667c5a/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a3a52a3ba86436ab3aef510fbe21512abc2ddd1993005dfe50514bd2284ef025", size = 377712, upload-time = "2026-10-04T16:29:43.242Z" }, + { url = "https://files.pythonhosted.org/packages/0c/8e/067e77d9d7b3cc793c9d909b7e97e7aadbbb1fb094093b6876902cc96d38/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d7841166b7fa64c9c56404617ae4341448847482d45933b13135d26c130519e5", size = 488106, upload-time = "2026-10-04T16:29:44.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b4/c5aae6c2dde269bf955f6b7d35065c655a57e47750d9668052ad67e74dda/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:926bdd3e3b5998ddf70cc64bc8cf57209571f9044542913afb673799fec77dd0", size = 390795, upload-time = "2026-10-04T16:29:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/a0/36/76fab39973ee11e7f9f357c55138197bb01c86f6502cb76487e3b4f42db0/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7868b85224291c6cb6759f9b5adb9745f486d226f62b16a614dd5a2a5ab2b35b", size = 370794, upload-time = "2026-10-04T16:29:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fe/cd2a80e6d7b871937a60e935c5d507aa390d143f4ff3636f640b9733d5df/rpds_py-2026.9.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3cd182d7291d29b92c521a0069d9c01ba6193628a9a105531d11b40a6d731a33", size = 375673, upload-time = "2026-10-04T16:29:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/6c/18/7464a9953724e55a3b3206062fa0ffdeaa519584c6aabf65d3956d94f131/rpds_py-2026.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e6ea1cda8d8c688278430e4268a42f5e5da3bdd74578dfadc0820c3f1766ce83", size = 398758, upload-time = "2026-10-04T16:29:50.601Z" }, + { url = "https://files.pythonhosted.org/packages/c0/86/1534b436700fd49ff411063b7c4d7e938adfabf90895b6cf1622d5a7d1f6/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5943980471829f6de242a20b109de3111ba6b77e3af0ffc587028ac854b05e6c", size = 550370, upload-time = "2026-10-04T16:29:52.002Z" }, + { url = "https://files.pythonhosted.org/packages/57/1c/e1fa82a8a01e3c5820f3ba98a8b2673f642128eb368fa88871b01dd2c909/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:76d3af9732d2dab69f28179b40ba2d87e2f1d5824b4a694780aa787d685e8f36", size = 613106, upload-time = "2026-10-04T16:29:53.655Z" }, + { url = "https://files.pythonhosted.org/packages/29/55/b20b8c4c3dde8755bfcd5b08492a02d0cd2e26929aedf6199ca2a377d42a/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:78326f4cb4427a56ba4996c0762b63be45f06b85f086526420d2b3a66e40f84d", size = 578235, upload-time = "2026-10-04T16:29:55.157Z" }, + { url = "https://files.pythonhosted.org/packages/55/42/df3f7bbc3f7ab37a8a9db8d6c2ff2c985422899f1f7926afbf7ec3c0b8b4/rpds_py-2026.9.1-cp313-cp313-win32.whl", hash = "sha256:172e47169583f46ce118cbec68e6795d0da0f4606b488b6434f8276bca0a058c", size = 205293, upload-time = "2026-10-04T16:29:56.669Z" }, + { url = "https://files.pythonhosted.org/packages/31/9c/ba5a9569d719bfdd6ce863df4133ac6a1658cf1b07cc3534c31db729fbbc/rpds_py-2026.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:3e93b2cd69a9830be33e03945cd7cda940a0a8bfcfbff41d6144f0cb0d3d8bd9", size = 222409, upload-time = "2026-10-04T16:29:58.049Z" }, + { url = "https://files.pythonhosted.org/packages/35/72/f28ca566f6c23c35bbf7445f65eb0364577b25a026305995e24f78b83d94/rpds_py-2026.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:d151e148117294133bf8af7eeace085e7e87432db15ab6adf640330298a47f6f", size = 217681, upload-time = "2026-10-04T16:29:59.449Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f2/67b94be1532767803415c1c5a1fd88ea487643d74a673cec1ba140af77bb/rpds_py-2026.9.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c9d1aca01f49170fdcf5c92761b1fafe97f554b721ca4570c5949fff778f0d4b", size = 347178, upload-time = "2026-10-04T16:30:00.865Z" }, + { url = "https://files.pythonhosted.org/packages/04/37/b751de2b59b0197a1d92a5dd491de88e8a5e928c2e6562581974f1e85263/rpds_py-2026.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3f0e9ac28fc067d4d34b88ae43c48e9489455c97fee9633d851f7eeed5a05d35", size = 341878, upload-time = "2026-10-04T16:30:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/72/e2/5873bc4643c250db9e05d48dc0c93763d4aa68bc3b81164cb1af3b45b284/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07deecbfce94c78473018bc7d10b337cc651d12df87a1eb2cb3e4024bc9c33d0", size = 373963, upload-time = "2026-10-04T16:30:04.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/03/5acf7632158247f3f6386ff0af3a1ee48167d575037e8d0920594b76d92b/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:821b2755db9194409254012f429c56643416fb96ef9be090be82ec8826b7f477", size = 377975, upload-time = "2026-10-04T16:30:05.555Z" }, + { url = "https://files.pythonhosted.org/packages/e6/00/63fda451b8bffa5808fc8bb311ee7c073b340974b09f273c7b2a145d3d62/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c91c210ae7645626c608400e3519b4a642f837cce09ca830db3beb2e9f274d4", size = 490347, upload-time = "2026-10-04T16:30:07.156Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ae/c093ffd070ba0fb02f76c565d06fecc65ad6e4afdbae78f7031076d3cdac/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54ac2158a6f96cfbabff0b2eedaf94b90c5ec7ca8317fcadc61e1c2b2e0ff6ef", size = 392962, upload-time = "2026-10-04T16:30:08.77Z" }, + { url = "https://files.pythonhosted.org/packages/22/9d/d08a1128ab199b2f0cf25bfeb0639bd05119fff4b7c47bec24ef9a8ec23f/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eac2f5dbafd585dfe31f86a23ebf0d3ba480a9d49ebc87947267b5608d4ea0cd", size = 371623, upload-time = "2026-10-04T16:30:10.501Z" }, + { url = "https://files.pythonhosted.org/packages/53/c7/4758ddcbb75609414bbccfcb11d612436f9b3ee821bd2f33f0f1604ee648/rpds_py-2026.9.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:8aa5dda18d39b6143eb24809d158f9252c88f402749b6f1b62a506cc7d96cc35", size = 376997, upload-time = "2026-10-04T16:30:12.124Z" }, + { url = "https://files.pythonhosted.org/packages/87/e4/947bd7f608ff60faf46dc9d389c3dffd0e3d767d78a0be19978448ef0ce7/rpds_py-2026.9.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5c90e7fa02e8f5de0d10c17595c568ada48c5302e749462c0ea1a4c362111a86", size = 400169, upload-time = "2026-10-04T16:30:13.804Z" }, + { url = "https://files.pythonhosted.org/packages/4b/35/fe93e020a0543b5670472c18d7e6af3197c08da571240ce1965c84f85c0f/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e6d198bad4e49dd6732fbd636e2fc5c082f45c8cad0b4acb756b00c82c76072e", size = 551615, upload-time = "2026-10-04T16:30:15.332Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4f/5d2a0136bb03b2a56a39dc6ff92d58a6e3e53a2e17079238b86228882f16/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:96beca19ec79de272e8668585380ff9092c47077c1d7a1e098e00bbd921f4785", size = 614655, upload-time = "2026-10-04T16:30:16.92Z" }, + { url = "https://files.pythonhosted.org/packages/09/1c/3f1025aaf70d9bf7272cc41f8b64ee76b48bf01726248138430e16f23b38/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5cf77eb04f20b720be95265a3e00eb2a14814074255cc27069c551b2db53118", size = 579602, upload-time = "2026-10-04T16:30:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/53/0d/5c72e6204f76610608706da32b6b7e11ef7e10317558a7bb15a122e008dc/rpds_py-2026.9.1-cp314-cp314-win32.whl", hash = "sha256:a03d57b86d2a51d0a66c92177e2be154ad015f357791d306e714569999cdb4cc", size = 206315, upload-time = "2026-10-04T16:30:20.05Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0b/489d48abbcc7d70cf3fbf662d9d22abf1f4650761c0a9ae05260800800d3/rpds_py-2026.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:837c6b305e26fe0f75b15c92cf3b2ba29e0ae19dc40b1c557b026cb426347d0c", size = 222852, upload-time = "2026-10-04T16:30:21.604Z" }, + { url = "https://files.pythonhosted.org/packages/91/16/bbb05a7e6a10cf79ba639be7f799d770ee15f64175cc61d081b218dd402a/rpds_py-2026.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:fce4b85234a0cbad67bf8e6e1201ee815d172c9aebad75f25645bc4d834f8e31", size = 218709, upload-time = "2026-10-04T16:30:23.036Z" }, + { url = "https://files.pythonhosted.org/packages/22/ac/ac507a0a4ec478ca470440a09583db4be5259ba7670aeba0620822f1e57a/rpds_py-2026.9.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3a72c11530d71abfb66c8d7696a2f86c43e63fca8b948f1a784ac490f4ec688e", size = 349596, upload-time = "2026-10-04T16:30:24.558Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f2/817a46b658d5070f477f722c298ee9a24525b0e4017347964146ef5fdd0e/rpds_py-2026.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:068c37bba854ec2fe42f7365c640af11dd9895890ccbf2df5070d0c059bd7f96", size = 343114, upload-time = "2026-10-04T16:30:26.048Z" }, + { url = "https://files.pythonhosted.org/packages/6c/42/6ade976b13ac1b4cb3bf2eb603f1be2fe74df19a29988d4c2b386be59d6f/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fca4eb6df565e2a928f1c7dad92d27db8f9df0f449e76423ed5d7e713ed445", size = 374197, upload-time = "2026-10-04T16:30:27.699Z" }, + { url = "https://files.pythonhosted.org/packages/d9/70/77cdf1d3f1a07faabe936016ae623aec7981f73108a8fe7a203ed2e21998/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c933c6678c6f116ff8af47a4c6db0868b8ace74af0343016c0ef00f00272ea69", size = 378025, upload-time = "2026-10-04T16:30:29.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/6b/18a44a3beaa9b7931acb04af7bd9539836477c630a794452d4826d6185d4/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:028ad274ea951dac64491b5d1e65712a4aeabfdbdb9fccf797b57bd899b0c495", size = 491969, upload-time = "2026-10-04T16:30:30.995Z" }, + { url = "https://files.pythonhosted.org/packages/73/27/fb39cfd6bddaf741b024f813890374578ff8ac1f1adc473659c048b03b05/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:740d0a99cf9de0b17a3943388e9294a59becf75e7c43421f387bd3c7a9901f7c", size = 393905, upload-time = "2026-10-04T16:30:32.628Z" }, + { url = "https://files.pythonhosted.org/packages/ed/71/0fa7bb77b57af0d710273964180d11b503f62b8a5c358eb2d8c3f62feff6/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0da298fb372dc192610a4b9ecbc68a0cd8b675bbbd1fc519d01b41cfd658333e", size = 374569, upload-time = "2026-10-04T16:30:34.257Z" }, + { url = "https://files.pythonhosted.org/packages/54/22/f41cfac269af3b449513ef1bc3d7f32fde52abbbd2d01c7e76b47743acd9/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:eb61be926bb81567c1f48bdc8aa22b9855048dc2efd53871f9f7e6e9a5632346", size = 377482, upload-time = "2026-10-04T16:30:35.997Z" }, + { url = "https://files.pythonhosted.org/packages/b4/fc/312b49006e7f8f9ca5f96647577b8aa6f3df30519c46bd448f5c425af0b2/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:42e75466f83cd43f6026c81eab74246efb2bdadafb307b85700632d06c68f299", size = 400040, upload-time = "2026-10-04T16:30:37.76Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a6/18cca7a878dc7fa95165a83343fd4d7b65643fd22e54e47340a451121d5c/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:617f59cde379b4f648a09797b7f683d04b90a46344cddab85639da5aff0f5531", size = 551849, upload-time = "2026-10-04T16:30:39.443Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6d/1f5685e20f39604691bdc3c05aaa6b8bd2f954e9e996477adf2376768e33/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3edae8c5ddfdb6985d49ae9d150516e5076888879022f91a26c2de9276ce0bdb", size = 614842, upload-time = "2026-10-04T16:30:41.231Z" }, + { url = "https://files.pythonhosted.org/packages/c6/25/98652109fd9f7e10268dd4571aa52b81987001b806f37ef1a18260de714a/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0f045bb053c9057720d72c56dffe30dffdc05997b2897a827b9325f0ab6623fa", size = 582606, upload-time = "2026-10-04T16:30:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/19/03/11ca09099bab5f53373a80a334c424ec917c13d050760a59499d2af5171e/rpds_py-2026.9.1-cp314-cp314t-win32.whl", hash = "sha256:bf35d0568abda97233239ce32896d3ad53fccc537832c104e30c94aa5fb93569", size = 203341, upload-time = "2026-10-04T16:30:44.954Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8a/88909e3ffd9f47f5b58211473875d8c3c09079f0058c46fb72c55a702a26/rpds_py-2026.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:1e8d4d79d828299bf44a55db22a9388ab967b49d17132c88eab0f4360b48da8e", size = 222843, upload-time = "2026-10-04T16:30:46.486Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b7/a662f367d4896dd0a10cef2fc91f10b7f08af1c10858e287e019563f338d/rpds_py-2026.9.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:1d77b649e6f7cdf12ca5c2a98dad0ad37f9ea9b6f960408a92f0cb12bb3d04d9", size = 347463, upload-time = "2026-10-04T16:30:48.203Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3f/ad45d03df4f84ebae5439577ee81f3999d182711e82235c8037b6528890e/rpds_py-2026.9.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:00ba2d8c7dd4ee537978ddf4b3fbd712bef2d8751603f7f3146b3f4287768e25", size = 342051, upload-time = "2026-10-04T16:30:49.872Z" }, + { url = "https://files.pythonhosted.org/packages/7e/31/3dcd68c13d4bcc59c1f7eb33ac8e80698f06f3eb0d8a1e06419836071c20/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec450527cbf485e13c8d3602a54f428ab0432fdade0ede75efd74b735421c871", size = 374143, upload-time = "2026-10-04T16:30:51.508Z" }, + { url = "https://files.pythonhosted.org/packages/cf/0d/68c1f058a250fbd1380ebda9fc227cbf50117adf8ffe8161ef383ea79f68/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:306ee1850d8105b5baf977e78d45fcadd12c1a54678d614c9baf217708446e91", size = 378604, upload-time = "2026-10-04T16:30:53.206Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d6/2d4c59b85397cb4800594fadf692688ccf5ce556adc930e7a5bf21061a5e/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ef6b65b03247c54692ad4fd9ee97cb772781927db72e3cb05e70b3db6d1ff14f", size = 490790, upload-time = "2026-10-04T16:30:54.925Z" }, + { url = "https://files.pythonhosted.org/packages/da/04/7e05dc3aebaf52f4e026766bd668fdd09a9d0e23f64a14686b36b3501892/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a575404ebc9cf2e91edd32eaf570ec1430eb900d4f56724ba7dd4bc1fc9c176d", size = 393146, upload-time = "2026-10-04T16:30:56.625Z" }, + { url = "https://files.pythonhosted.org/packages/57/ca/e2e9a0a46a74ed51a0498ba1fe10f4ea6b2d9a155f372a2f91e51f18cf10/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c16ab111bc27c646ba8aa005d0527754edc538ebb636f0b1bf8e244b48d1945", size = 372022, upload-time = "2026-10-04T16:30:58.295Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cb/8f8774df5134e23424372838bcc5c7ed4127d723e1ff52f7bebd4dcb2563/rpds_py-2026.9.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:7664419f27db41d4f1c43a78dccda7dd6e8ef2428df3ee01d0c2a07a6b071297", size = 377092, upload-time = "2026-10-04T16:30:59.984Z" }, + { url = "https://files.pythonhosted.org/packages/90/02/8d7095d73bf9114219be40230baa5df00611e0a82ed9517779ff9c19f82b/rpds_py-2026.9.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4b26b03d9d2658ee2fa234f8f4f19f38a09773fe5261028025032e26d4d35af0", size = 400830, upload-time = "2026-10-04T16:31:01.721Z" }, + { url = "https://files.pythonhosted.org/packages/ec/02/8206856f8f363cd042a8315dc86b3912f5dcb6d3c66bbb24b69c2bfb0775/rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:be3e47e2d91aa3942ff9bf4077a505226005abfc39b6f7554a91c1b9393986b9", size = 551478, upload-time = "2026-10-04T16:31:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/41/6b/36211f1bb1f0b0313f496d92f5905b74ea107f27cb16fa3355a82f04575e/rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:6307a0da524939decb8ca4a3933b8ab62525794411d6984fca6726e732804af6", size = 615173, upload-time = "2026-10-04T16:31:05.281Z" }, + { url = "https://files.pythonhosted.org/packages/35/77/cda0c4a6f055446b692f0ed5692f73707cafd3dff82672ede31b1a9b59de/rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:159a7aab5c5e8b112c8830f54717ce56da1252ebdbb526f5be2df2309280b9e7", size = 579653, upload-time = "2026-10-04T16:31:07.065Z" }, + { url = "https://files.pythonhosted.org/packages/74/ec/d8385f446240aed643b9e92a5055cff3015cc04a13c61f73b2883c478ed5/rpds_py-2026.9.1-cp315-cp315-win32.whl", hash = "sha256:dbc2673f9223d420c91145599b3ba45a8a50c207d1976908e5fb5ddb0c9b9429", size = 206498, upload-time = "2026-10-04T16:31:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a5/71b5cd00e0521e3b6b81828baea368c62b6b700ebdd9554cd7d41ddf12fa/rpds_py-2026.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:75c38c50ab9aca840225d9a9a3810bf11d04bd5c1f186cabbb8aee56db3e9b15", size = 223121, upload-time = "2026-10-04T16:31:10.84Z" }, + { url = "https://files.pythonhosted.org/packages/33/58/dba857c3bc8221b31b62eb170a3080f4f191de79f047200389b7ed1b06a7/rpds_py-2026.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:a431156bb41865fc14cd5d79bb9d7bbed83110b0159e34e62ae30951f96c0009", size = 218741, upload-time = "2026-10-04T16:31:12.592Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d0/320ab28ccc1415eeb509d68682b0014fb74690cd49f1c2d29a232475af50/rpds_py-2026.9.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ef0d8c843e2827d6c120ab4687e9423fb1d893db1df27b7c1506615bcb9734a0", size = 349719, upload-time = "2026-10-04T16:31:14.48Z" }, + { url = "https://files.pythonhosted.org/packages/56/88/f5b12f1358f443c08b7ce3cc8391d82d335f4872580e2e097847fd36087a/rpds_py-2026.9.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:45bc6bccf78b20fd834237d18db64965d7ee68ba7f60440a26c7ab71e7b8d51a", size = 343267, upload-time = "2026-10-04T16:31:16.827Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0c/c765b0059d532acb3b9c45d781ccc22f15a96dbe443d00903f643ba9df10/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d55198263bb51f557550c6ed2e6d1cb6a6fed6eb5c9120b741c5926bef8a45d", size = 374555, upload-time = "2026-10-04T16:31:18.931Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8a/cafddfda77564a10cd21184640c3bffda6a2b8d20972fb5dedd5e0166328/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8763f20692da7df39b0afdd1ba3042b004c50a45994f76c2d9a25641f7673db", size = 378346, upload-time = "2026-10-04T16:31:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/b9/01/5e626016eff72c183bf6c96539240cace15d402468647a453ec08415b2fc/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e43d4a1f673e8a1cbd8533e809e02b4bf9d4f2280269bb640436556312121250", size = 492373, upload-time = "2026-10-04T16:31:22.614Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9c/15a2469e9389242f46896b3f0a01d68caea8a5a35c011fcb05ef333aae73/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea394a937f17a54c51239348bdbe2e3518124c8d4a8951ba04a311d3095bd18f", size = 394371, upload-time = "2026-10-04T16:31:24.768Z" }, + { url = "https://files.pythonhosted.org/packages/63/f5/c100ff77e1e6366e947c75969c258fdfc4b7bc5bbfe7351e62ffbf2a1228/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdeaa99ce822dca76cfb1b993e9120c5ea212f2eb66d48950ad63c349668a018", size = 375132, upload-time = "2026-10-04T16:31:26.588Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f4/fe0269c9de253e99c81cabc12b8971a5feaa083debdaff1221e06264d9e3/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:b4f062343e7ad3fa94f2c66e5ae667dee47ee74dd41a9057c4fbe163236a123d", size = 377642, upload-time = "2026-10-04T16:31:28.677Z" }, + { url = "https://files.pythonhosted.org/packages/05/65/b34a7b257baccff8f4a24a722933166d4941d5ebdc9c3f4bc4ffcd5ce4f4/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:22ffd29a63d71fb1b81552c21f2c2b734949b7ac751a9be70675a939a900839b", size = 400855, upload-time = "2026-10-04T16:31:30.802Z" }, + { url = "https://files.pythonhosted.org/packages/98/32/844e54176b6071b90b38a564e6940bc6eb8f97b2890dc709c19db9dec0f4/rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:08dae4a4095150a7c4545a1fb40b98e1ab1744fbc2770d92c977b9dadaa49ab6", size = 552573, upload-time = "2026-10-04T16:31:32.709Z" }, + { url = "https://files.pythonhosted.org/packages/17/73/6041d20729dffbfdf155c02d65be58bc225a1c1fb548fd87c23ef306138f/rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9a0460d43603d1fd9ef59c30278531e15d78581721ddb538fa560aa7817ea4ad", size = 615840, upload-time = "2026-10-04T16:31:34.565Z" }, + { url = "https://files.pythonhosted.org/packages/af/9e/418094adaee6b056ce199051b255448ed872829051e341b2294c80da0977/rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:1c2d1f6da5128eabf34e963d7163a818846075a52568250d006c4c953b40f903", size = 582764, upload-time = "2026-10-04T16:31:36.665Z" }, + { url = "https://files.pythonhosted.org/packages/3d/9b/1698ebf6b840ddfe8b472198abbed6ace35c6e398faeecd6c47dae742a4e/rpds_py-2026.9.1-cp315-cp315t-win32.whl", hash = "sha256:5c6ee90dee3e85e055ddfd502d611643d9b0fd94c818220bda84ec3dacd9b27b", size = 203795, upload-time = "2026-10-04T16:31:38.53Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e2/91f70d804c61f8eac39a417e82aa1024f655ab9e8bdd7393b196242bc41b/rpds_py-2026.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:fe5ad0664ec772b02c45859041aa17655709cced7a31005817fbbbd988c25567", size = 223037, upload-time = "2026-10-04T16:31:40.468Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/f0eba548e297987964ef8088403db875b333963bf8189f3f563b6936b20e/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c0d2cb595a420b34d5086db0add011e26e2c09d6a024afbac4228bf8f863a30", size = 384112, upload-time = "2026-10-04T16:31:42.643Z" }, + { url = "https://files.pythonhosted.org/packages/33/89/891fa4d81f1eae7fe262ecd0aba2c51de94a116c7f3ccfb034fa75f4c537/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f3d6ed6a98cfd19155996605474982cc470d7601746a6439078f1a5a3fa8b050", size = 389662, upload-time = "2026-10-04T16:31:44.642Z" }, + { url = "https://files.pythonhosted.org/packages/4f/46/912cf623b2263f9c9c2d04e8508fc38dbe89983faeba5fb36ca4558ae550/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8171b44a054e5c67fd748ada04187f1250bf35b95f85e52ab64bcf3331a923bb", size = 504637, upload-time = "2026-10-04T16:31:46.667Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5c/7f3cfa7c5f01765c9f47c8398210ed0c1eb686c0e99c223cb1c6776d3fb9/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fda1d96e542c37b6c804547dbf489c129fe7c97183a76a5ec275909ba1a063df", size = 402607, upload-time = "2026-10-04T16:31:49.068Z" }, + { url = "https://files.pythonhosted.org/packages/f0/76/2e915d4115ee453c33e6af17bc1df7e22ddc52974e178eeeb92a78c09983/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:270bdcdaac5d5b6f73c5e22e7e135c7f2a50e789f71d9e241d5be8d90026e19a", size = 382762, upload-time = "2026-10-04T16:31:51.24Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e5/844382925d7b84a4074ad79fc2b9d05eb6231ea243b34f247e68e519b606/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:84a6ecc0c940169190d2c23bd969debd48c94dbc855acd60188a68d71d421608", size = 389563, upload-time = "2026-10-04T16:31:53.549Z" }, + { url = "https://files.pythonhosted.org/packages/81/82/d6e3951602dc2e24790ef9d299aa24bf5e2493df5043f2c6da00480cc447/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ab4b2fda7c2b542f7f9d886cc6a838c5079d2b76f72e6081411faba11adde2c9", size = 416842, upload-time = "2026-10-04T16:31:55.76Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1c/8d248e4ea671d525ab7daa0703ad81cc2175076fa0ccd7ef088facbd2b01/rpds_py-2026.9.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:44b32a7c4f0da3d28af31c259e38ddcff096f855e205ed0671d02fcf44f1ea1c", size = 562434, upload-time = "2026-10-04T16:31:57.874Z" }, + { url = "https://files.pythonhosted.org/packages/ad/38/e7ac0db898120d15dea25e3661b1242a40cb3b23d1f4db86c15a01fe0ba1/rpds_py-2026.9.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:a3dbc5ed9514908d5046107d7b1346bde71eea61de6e0e4919c19354f97e769f", size = 627909, upload-time = "2026-10-04T16:31:59.977Z" }, + { url = "https://files.pythonhosted.org/packages/03/c8/37e555514932cb1ee36c75349187aa882a98dee5d5654c761699ed8a0d27/rpds_py-2026.9.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6eae33003518fd4cb4f83a218d5371469dd3001aa3b87128c005b07762f7fe5e", size = 593658, upload-time = "2026-10-04T16:32:02.14Z" }, + { url = "https://files.pythonhosted.org/packages/12/5c/5f69a91add94a8a33acea68a2b034a464946a654ff7296af9d957ef0d6a5/rpds_py-2026.9.1-pp311-pypy311_pp80-macosx_10_12_x86_64.whl", hash = "sha256:0483515261947e4e8b8e1375bf7463e7eb6ccfb3d86e7b554d90cd5285f20f32", size = 351769, upload-time = "2026-10-04T16:32:04.675Z" }, + { url = "https://files.pythonhosted.org/packages/41/22/35ea8bf3b2682b90ab687c74e4828418ff12298a5b0b0a0c033f61c86a30/rpds_py-2026.9.1-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:4cfaf02209061880210819934de2f4f6aa83dc04dafe6770276acc240a56da31", size = 351719, upload-time = "2026-10-04T16:32:06.834Z" }, + { url = "https://files.pythonhosted.org/packages/a3/90/a9ba81408369d34c1aca73319c15adc91ed45c1d21e9e8ee4832ffb7fe0b/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cdc537c8633d7fd92a82e2e0d2ab74320a3f63d5e59fb9cf08711e08fe151c4", size = 381109, upload-time = "2026-10-04T16:32:08.994Z" }, + { url = "https://files.pythonhosted.org/packages/b3/44/5192a0bed94cec86bd2a5e1cc5a1bb8f7eec3aec907eacd2deb13e87e326/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:10e208f2425d973938afcd56e28a7c4be32e27b6a60b5d381f49fb9d8acf9759", size = 388222, upload-time = "2026-10-04T16:32:11.321Z" }, + { url = "https://files.pythonhosted.org/packages/92/cd/5356549711448f18b52f7a11ffbe90f1228774996fbf3c6cd1b18d22abaf/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c0dbbcc19735fe5f8b0a54c07659d154a9e69f47e15d0a6ab7299215daf62cb", size = 500384, upload-time = "2026-10-04T16:32:13.478Z" }, + { url = "https://files.pythonhosted.org/packages/7a/88/ddda9d28adfe33119c75b2e733d4ba7e326f41d7e1b1093951771768ca48/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684fd492fff4fead00587544e059be2bbcb6f93454f21fa2a91b66fc7508be82", size = 401661, upload-time = "2026-10-04T16:32:15.653Z" }, + { url = "https://files.pythonhosted.org/packages/32/c3/bb59ba16a6b4a57995d15b8c04c00f28df3b938c4dcdde48fbe0f73edb0c/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1028417bb44037eb3069c1009bd7b7277212876cda22fbe565b0bca9fab6d2c", size = 380748, upload-time = "2026-10-04T16:32:17.727Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b8/0580faa1c5a32ddc160130dd7ae54d2d007272a89a40a6b5db3e50f9f585/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:492e5e428cbe126221611f47e068f01660352feec4ad18bc0f5ea9b2ae88fb14", size = 385731, upload-time = "2026-10-04T16:32:19.855Z" }, + { url = "https://files.pythonhosted.org/packages/bf/70/f73564642bbe3322c7eeef2c2f258cf040b71c41a430b532e59d04a1b838/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:88b5268892fde430d5531f95bc560b6efbbd67c929662c586afd729a96e7461c", size = 412131, upload-time = "2026-10-04T16:32:22.285Z" }, + { url = "https://files.pythonhosted.org/packages/8c/5d/17fff2e1f8f68721a2afb5cb48f7e442c751bb6b4883157c5abaf618cd08/rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:01445c8d194aa032a08e944f16567672da1c62dbdbefd8b6d0693032e290cf68", size = 558193, upload-time = "2026-10-04T16:32:24.4Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d0/869ab6fb08531f97aae4a780b4d61b4190d8aef48fe054ee28bcf7114466/rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_i686.whl", hash = "sha256:eef6a03b0b6d08d0835ccfa8ec8d1bc70525e3801387567137b50c557695e6da", size = 622448, upload-time = "2026-10-04T16:32:26.844Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a5/9672e532fe02cd3b92c78cfd8743abca939d96853006c63835009a1ecb6f/rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6b9bf3135b4ad5981df9a73d71a35272d650a2985ae9c2746357b24d59de2448", size = 591126, upload-time = "2026-10-04T16:32:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/80/ef/3a9f8e4279c7920af561deed4cb7ebebed2794a8f608cf404d5eef14a105/rpds_py-2026.9.1-pp312-pypy312_pp80-macosx_10_12_x86_64.whl", hash = "sha256:56c6952a9b15047466d0c2347c446a761d4527f89976156341e68f0ce5cc08b0", size = 357108, upload-time = "2026-10-04T16:32:31.641Z" }, + { url = "https://files.pythonhosted.org/packages/b7/55/4b2fa381a583760aea5c92841e4e928a358e1c6511b129219e9c2826a226/rpds_py-2026.9.1-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:b242c27c8f836305a4a72df9cdd564386ac57b807bd252a063223331c9316b37", size = 346877, upload-time = "2026-10-04T16:32:34.061Z" }, +] + [[package]] name = "ruff" version = "0.17.0" @@ -1974,6 +2342,32 @@ 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.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/7f/3b8ec4f27833f7b8c270a3ef960b6d435e1065439847bc0fcd1e58ae898b/sse_starlette-3.6.0.tar.gz", hash = "sha256:623151d6c20dc0eebf1b3379c27faae91fff883bac7ab6665f16f46440a88b3a", size = 41174, upload-time = "2026-10-25T20:31:59.372Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/49/35053078cdfcf639237e8bee6dc6f34f1b3a3cc8d01800a1e213e1660ff6/sse_starlette-3.6.0-py3-none-any.whl", hash = "sha256:e4dfa4d237d2712cb2b4d4918eda9b457cd6b44bbecdc838af75de836565bafd", size = 17985, upload-time = "2026-10-25T20:31:58.134Z" }, +] + +[[package]] +name = "starlette" +version = "1.10.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/48/bb/3d8757f21e9a53d9035c9eb039a777047139a16bfdbedfc7a58241c3d914/starlette-1.10.0.tar.gz", hash = "sha256:b8399098d4c1e3522734f7235dc79b1fc967f96cf139c81e0fa30479e0685f9b", size = 2734356, upload-time = "2026-10-24T15:04:12.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/2e/947cfe02def9371c07cd5bf5b6b61c829e9aecb9904db02a66cfafbc1eec/starlette-1.10.0-py3-none-any.whl", hash = "sha256:478c0ab3b4ced6e82719849c118fa406f0278a3499c1a898cd15cd31bdc38ce4", size = 80571, upload-time = "2026-10-24T15:04:10.371Z" }, +] + [[package]] name = "submilli" version = "0.1.2" @@ -2147,6 +2541,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/e4/2bdff71a73c3d5dde6777815c910be7417fbc54f36206d0f7a1fb2f54522/uuid_utils-0.17.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6cca251f83d3ec2988fb7355b52c4b4fd2d96159f6f040e68f51af15fab49540", size = 180288, upload-time = "2026-09-08T11:29:34.24Z" }, ] +[[package]] +name = "uvicorn" +version = "0.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" }, +] + [[package]] name = "vcrpy" version = "8.3.0"