From 3f7932d48209c5749575262e0cc9686ccfe3eba9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 10:12:55 +0000 Subject: [PATCH 1/5] Add openai-agents-submilli: Submilli tools for the OpenAI Agents SDK --- packages/openai-agents-submilli/CHANGELOG.md | 0 packages/openai-agents-submilli/LICENSE | 201 ++++++++ packages/openai-agents-submilli/NOTES.md | 0 packages/openai-agents-submilli/README.md | 1 + .../openai-agents-submilli/pyproject.toml | 76 +++ .../src/openai_agents_submilli/__init__.py | 31 ++ .../src/openai_agents_submilli/_app_tools.py | 196 +++++++ .../src/openai_agents_submilli/_program.py | 290 +++++++++++ .../src/openai_agents_submilli/_sessions.py | 114 +++++ .../src/openai_agents_submilli/_toolkit.py | 484 ++++++++++++++++++ .../src/openai_agents_submilli/_tools.py | 253 +++++++++ .../src/openai_agents_submilli/py.typed | 0 .../openai-agents-submilli/tests/__init__.py | 0 .../tests/integration_tests/__init__.py | 0 .../openai-agents-submilli/tests/scripted.py | 110 ++++ .../tests/unit_tests/__init__.py | 0 .../tests/unit_tests/fake_server.py | 284 ++++++++++ .../tests/unit_tests/test_app_tools.py | 240 +++++++++ .../tests/unit_tests/test_toolkit.py | 309 +++++++++++ pyproject.toml | 4 +- uv.lock | 407 +++++++++++++++ 21 files changed, 2998 insertions(+), 2 deletions(-) create mode 100644 packages/openai-agents-submilli/CHANGELOG.md create mode 100644 packages/openai-agents-submilli/LICENSE create mode 100644 packages/openai-agents-submilli/NOTES.md create mode 100644 packages/openai-agents-submilli/README.md create mode 100644 packages/openai-agents-submilli/pyproject.toml create mode 100644 packages/openai-agents-submilli/src/openai_agents_submilli/__init__.py create mode 100644 packages/openai-agents-submilli/src/openai_agents_submilli/_app_tools.py create mode 100644 packages/openai-agents-submilli/src/openai_agents_submilli/_program.py create mode 100644 packages/openai-agents-submilli/src/openai_agents_submilli/_sessions.py create mode 100644 packages/openai-agents-submilli/src/openai_agents_submilli/_toolkit.py create mode 100644 packages/openai-agents-submilli/src/openai_agents_submilli/_tools.py create mode 100644 packages/openai-agents-submilli/src/openai_agents_submilli/py.typed create mode 100644 packages/openai-agents-submilli/tests/__init__.py create mode 100644 packages/openai-agents-submilli/tests/integration_tests/__init__.py create mode 100644 packages/openai-agents-submilli/tests/scripted.py create mode 100644 packages/openai-agents-submilli/tests/unit_tests/__init__.py create mode 100644 packages/openai-agents-submilli/tests/unit_tests/fake_server.py create mode 100644 packages/openai-agents-submilli/tests/unit_tests/test_app_tools.py create mode 100644 packages/openai-agents-submilli/tests/unit_tests/test_toolkit.py diff --git a/packages/openai-agents-submilli/CHANGELOG.md b/packages/openai-agents-submilli/CHANGELOG.md new file mode 100644 index 0000000..e69de29 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..e69de29 diff --git a/packages/openai-agents-submilli/README.md b/packages/openai-agents-submilli/README.md new file mode 100644 index 0000000..a0c46f1 --- /dev/null +++ b/packages/openai-agents-submilli/README.md @@ -0,0 +1 @@ +# openai-agents-submilli 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..41fd9f9 --- /dev/null +++ b/packages/openai-agents-submilli/src/openai_agents_submilli/_program.py @@ -0,0 +1,290 @@ +"""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 (UnknownRunError, UnknownSessionError) as error: + execution_id = getattr(error, "execution_id", None) or "" + outcome = t.lost_run_outcome(execution_id=execution_id, 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..0b7270f --- /dev/null +++ b/packages/openai-agents-submilli/src/openai_agents_submilli/_toolkit.py @@ -0,0 +1,484 @@ +"""`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, + RunState, + 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 + outcome: t.Outcome | None = None + state: RunState | None = None + while True: + try: + state = await self.client.sessions.execute(session["id"], code, idempotency_key=key) + 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 + except IdempotencyError as error: + outcome = t.idempotency_outcome(error, session_id=session["id"]) + break + except RunStateError as error: + outcome = t.run_state_outcome(error, session_id=session["id"], tool_calls=[]) + break + if self._program_agent is not None: + # Always through the nested agent, even for a run that finished: on + # a resume, the SDK holds the nested run and expects it to end. + 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) + if outcome is None: + # Interrupted for an approval: the SDK holds the output back. + return t.as_text(output) + elif outcome is None: + assert isinstance(state, ExecuteResult), "a session without application tools only finishes runs" + outcome = t.execute_outcome(state, notice=notice) + content, data = outcome + self._remember(context.tool_call_id, data) + return json.dumps(content, ensure_ascii=False) + + 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..b374f1e --- /dev/null +++ b/packages/openai-agents-submilli/src/openai_agents_submilli/_tools.py @@ -0,0 +1,253 @@ +"""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` and `denied`.""" + 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} + if tool_calls: + data["tool_calls"] = tool_calls + return content, data + + +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. 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"} + if tool_calls: + data["tool_calls"] = 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} + if tool_calls: + data["tool_calls"] = 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/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..3dd1acd --- /dev/null +++ b/packages/openai-agents-submilli/tests/unit_tests/test_app_tools.py @@ -0,0 +1,240 @@ +"""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, TypedDict + +import pytest +import respx +from agents import Agent, RunContextWrapper, Runner, RunResult, RunState, function_tool, set_tracing_disabled +from agents.items import ToolCallOutputItem +from pydantic import BaseModel + +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" + + +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] 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..9113736 --- /dev/null +++ b/packages/openai-agents-submilli/tests/unit_tests/test_toolkit.py @@ -0,0 +1,309 @@ +"""`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"}) 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" From b74afa4d13d480184b618ec1bed21493d9443214 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 10:18:53 +0000 Subject: [PATCH 2/5] openai-agents-submilli: integration tests, docs, CI and release wiring --- .github/workflows/ci.yml | 9 +- .github/workflows/release.yml | 8 +- README.md | 29 +- packages/openai-agents-submilli/CHANGELOG.md | 24 ++ packages/openai-agents-submilli/NOTES.md | 169 +++++++++ packages/openai-agents-submilli/README.md | 208 ++++++++++++ .../tests/integration_tests/conftest.py | 108 ++++++ .../tests/integration_tests/test_server.py | 320 ++++++++++++++++++ .../tests/unit_tests/test_app_tools.py | 28 +- .../tests/unit_tests/test_toolkit.py | 6 + 10 files changed, 892 insertions(+), 17 deletions(-) create mode 100644 packages/openai-agents-submilli/tests/integration_tests/conftest.py create mode 100644 packages/openai-agents-submilli/tests/integration_tests/test_server.py 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 index e69de29..5344843 100644 --- a/packages/openai-agents-submilli/CHANGELOG.md +++ 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/NOTES.md b/packages/openai-agents-submilli/NOTES.md index e69de29..366ad8c 100644 --- a/packages/openai-agents-submilli/NOTES.md +++ b/packages/openai-agents-submilli/NOTES.md @@ -0,0 +1,169 @@ +# 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. +- **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 execute tool first executes + itself (that is where session replacement and secret rebinds happen, with the run's context), + and then always hands over to the nested agent, even when the run already finished. 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. +- **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`), + 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 index a0c46f1..bed1446 100644 --- a/packages/openai-agents-submilli/README.md +++ b/packages/openai-agents-submilli/README.md @@ -1 +1,209 @@ # 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`, and `denied`. The + output item's `custom_data`, which the model never sees, contains `execution_id`, + `session_id`, and any application-tool calls, 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, as +long as both use the same `store`. + +## 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/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..f3e2e53 --- /dev/null +++ b/packages/openai-agents-submilli/tests/integration_tests/test_server.py @@ -0,0 +1,320 @@ +"""`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"] + + +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/unit_tests/test_app_tools.py b/packages/openai-agents-submilli/tests/unit_tests/test_app_tools.py index 3dd1acd..99d3580 100644 --- a/packages/openai-agents-submilli/tests/unit_tests/test_app_tools.py +++ b/packages/openai-agents-submilli/tests/unit_tests/test_app_tools.py @@ -4,13 +4,15 @@ import json from collections.abc import Iterator -from typing import Any, TypedDict +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 @@ -238,3 +240,27 @@ def double(n: int) -> int: 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 index 9113736..ab519e1 100644 --- a/packages/openai-agents-submilli/tests/unit_tests/test_toolkit.py +++ b/packages/openai-agents-submilli/tests/unit_tests/test_toolkit.py @@ -307,3 +307,9 @@ async def test_application_errors_raise(server: FakeServer) -> None: 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" From d28f38a392436cf49899f1161eb50ff2e0d5dc63 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 10:26:04 +0000 Subject: [PATCH 3/5] Run ptc programs through the nested agent from the first execute A resumed approval never starts the program again in another session. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014d2JueaFi2TFkyy2qcvqpL --- packages/openai-agents-submilli/NOTES.md | 21 ++++-- packages/openai-agents-submilli/README.md | 5 +- .../src/openai_agents_submilli/_program.py | 13 +++- .../src/openai_agents_submilli/_toolkit.py | 72 +++++++++++-------- .../tests/integration_tests/test_server.py | 19 +++++ 5 files changed, 89 insertions(+), 41 deletions(-) diff --git a/packages/openai-agents-submilli/NOTES.md b/packages/openai-agents-submilli/NOTES.md index 366ad8c..3670b98 100644 --- a/packages/openai-agents-submilli/NOTES.md +++ b/packages/openai-agents-submilli/NOTES.md @@ -100,11 +100,19 @@ This is where the design differs most from LangChain, and it is worth reading be 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 execute tool first executes - itself (that is where session replacement and secret rebinds happen, with the run's context), - and then always hands over to the nested agent, even when the run already finished. 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. +- **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 @@ -147,7 +155,8 @@ This is where the design differs most from LangChain, and it is worth reading be 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`), - session reuse and isolation, replay, a mismatch, a restart, and an expired session. + 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. diff --git a/packages/openai-agents-submilli/README.md b/packages/openai-agents-submilli/README.md index bed1446..2bb6aae 100644 --- a/packages/openai-agents-submilli/README.md +++ b/packages/openai-agents-submilli/README.md @@ -178,8 +178,9 @@ while result.interruptions: 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, as -long as both use the same `store`. +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. ## Submilli documentation diff --git a/packages/openai-agents-submilli/src/openai_agents_submilli/_program.py b/packages/openai-agents-submilli/src/openai_agents_submilli/_program.py index 41fd9f9..08e207d 100644 --- a/packages/openai-agents-submilli/src/openai_agents_submilli/_program.py +++ b/packages/openai-agents-submilli/src/openai_agents_submilli/_program.py @@ -164,9 +164,16 @@ async def on_call(call: PendingCall) -> ToolResult: except RunStateError as error: outcome = t.run_state_outcome(error, session_id=session_id, tool_calls=records) return _response(_message(final_text(outcome))) - except (UnknownRunError, UnknownSessionError) as error: - execution_id = getattr(error, "execution_id", None) or "" - outcome = t.lost_run_outcome(execution_id=execution_id, session_id=session_id, tool_calls=records) + 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}") diff --git a/packages/openai-agents-submilli/src/openai_agents_submilli/_toolkit.py b/packages/openai-agents-submilli/src/openai_agents_submilli/_toolkit.py index 0b7270f..b578a54 100644 --- a/packages/openai-agents-submilli/src/openai_agents_submilli/_toolkit.py +++ b/packages/openai-agents-submilli/src/openai_agents_submilli/_toolkit.py @@ -28,7 +28,6 @@ AsyncSubmilli, ExecuteResult, IdempotencyError, - RunState, RunStateError, SessionRequiresSecretsError, SubmilliError, @@ -249,11 +248,9 @@ async def _execute(self, context: ToolContext[Any], arguments: dict[str, Any]) - key = idempotency_key(context.tool_call_id) if context.tool_call_id else None notice = None rebound = False - outcome: t.Outcome | None = None - state: RunState | None = None while True: try: - state = await self.client.sessions.execute(session["id"], code, idempotency_key=key) + output = await self._run(context, code, session["id"], key, notice) break except UnknownSessionError: if notice is not None: @@ -268,35 +265,50 @@ async def _execute(self, context: ToolContext[Any], arguments: dict[str, Any]) - ) from error await self.client.sessions.rebind(session["id"], self._secrets(context.context)) rebound = True - except IdempotencyError as error: - outcome = t.idempotency_outcome(error, session_id=session["id"]) - break - except RunStateError as error: - outcome = t.run_state_outcome(error, session_id=session["id"], tool_calls=[]) - break - if self._program_agent is not None: - # Always through the nested agent, even for a run that finished: on - # a resume, the SDK holds the nested run and expects it to end. - 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) - if outcome is None: - # Interrupted for an approval: the SDK holds the output back. - return t.as_text(output) - elif outcome is None: - assert isinstance(state, ExecuteResult), "a session without application tools only finishes runs" - outcome = t.execute_outcome(state, notice=notice) - content, data = outcome + 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)) diff --git a/packages/openai-agents-submilli/tests/integration_tests/test_server.py b/packages/openai-agents-submilli/tests/integration_tests/test_server.py index f3e2e53..a0ed863 100644 --- a/packages/openai-agents-submilli/tests/integration_tests/test_server.py +++ b/packages/openai-agents-submilli/tests/integration_tests/test_server.py @@ -208,6 +208,25 @@ async def test_each_call_waits_for_its_own_approval_across_processes(managed: Ma 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} From 1fd8fe6266b1341129e9b62ec593aef285a2e700 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 18:26:17 +0000 Subject: [PATCH 4/5] Show the model a program's application-tool calls The execute output now carries `tool_calls` (tool, arguments, outcome) for the model, without the call ids that stay in `custom_data` for the audit trail. In a live run with a real model, a program that refunded one order and was then refused the next left the model unable to say which refund had gone through. The lost-run message now points at the listed calls too. @submilli/ai-sdk already shows the calls the same way. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014d2Jufg2VYGnwUi5xQeE1C --- packages/openai-agents-submilli/NOTES.md | 5 +++- packages/openai-agents-submilli/README.md | 8 +++--- .../src/openai_agents_submilli/_tools.py | 26 +++++++++++++------ .../tests/unit_tests/test_app_tools.py | 4 +++ 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/packages/openai-agents-submilli/NOTES.md b/packages/openai-agents-submilli/NOTES.md index 3670b98..d130d89 100644 --- a/packages/openai-agents-submilli/NOTES.md +++ b/packages/openai-agents-submilli/NOTES.md @@ -28,7 +28,10 @@ text for the model. - **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. + 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 diff --git a/packages/openai-agents-submilli/README.md b/packages/openai-agents-submilli/README.md index 2bb6aae..0b7956b 100644 --- a/packages/openai-agents-submilli/README.md +++ b/packages/openai-agents-submilli/README.md @@ -107,9 +107,11 @@ instructions for using them from the Submilli server. - 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`, and `denied`. The - output item's `custom_data`, which the model never sees, contains `execution_id`, - `session_id`, and any application-tool calls, for linking to the audit trail. +- 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. diff --git a/packages/openai-agents-submilli/src/openai_agents_submilli/_tools.py b/packages/openai-agents-submilli/src/openai_agents_submilli/_tools.py index b374f1e..190c3ad 100644 --- a/packages/openai-agents-submilli/src/openai_agents_submilli/_tools.py +++ b/packages/openai-agents-submilli/src/openai_agents_submilli/_tools.py @@ -141,7 +141,7 @@ def descriptions(prompt: BlueprintPrompt) -> dict[str, str]: 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` and `denied`.""" + """A finished run: the model gets `result`, `console`, `error`, `denied` and the tool calls.""" content: dict[str, Any] = { "result": result.result, "console": result.console, @@ -155,14 +155,26 @@ def execute_outcome( if notice: content["notice"] = notice data: dict[str, Any] = {"execution_id": result.execution_id, "session_id": result.session_id} - if tool_calls: - data["tool_calls"] = tool_calls + _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. Its effects up to that point are unknown. Check what it would have changed before " + "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." ) @@ -176,8 +188,7 @@ def lost_run_outcome(*, execution_id: str, session_id: str, tool_calls: list[dic "denied": False, } data: dict[str, Any] = {"execution_id": execution_id, "session_id": session_id, "refusal": "unknown_run"} - if tool_calls: - data["tool_calls"] = tool_calls + _add_tool_calls(content, data, tool_calls) return content, data @@ -229,8 +240,7 @@ def run_state_outcome(error: RunStateError, *, session_id: str, tool_calls: list 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} - if tool_calls: - data["tool_calls"] = tool_calls + _add_tool_calls(content, data, tool_calls) return content, data 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 index 99d3580..31156ec 100644 --- a/packages/openai-agents-submilli/tests/unit_tests/test_app_tools.py +++ b/packages/openai-agents-submilli/tests/unit_tests/test_app_tools.py @@ -185,6 +185,10 @@ async def test_a_rejection_is_a_denial_in_the_program(server: FakeServer) -> Non 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: From 733ec7c69cb152f5ccf726d7c275d5c1d40ff988 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 19:33:52 +0000 Subject: [PATCH 5/5] Explain how to move on from an unanswered approval A live run showed that continuing a conversation with the pending execute call still in the input makes the Responses API refuse the next run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014d2Jufg2VYGnwUi5xQeE1C --- packages/openai-agents-submilli/README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/openai-agents-submilli/README.md b/packages/openai-agents-submilli/README.md index 0b7956b..91525e4 100644 --- a/packages/openai-agents-submilli/README.md +++ b/packages/openai-agents-submilli/README.md @@ -184,6 +184,11 @@ can be saved with `to_string()` and resumed with `RunState.from_string` in anoth 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/)