diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d34b580..c2f0a25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,8 @@ permissions: contents: read env: - # The submilli-server release the integration tests run against. - SUBMILLI_VERSION: "0.4.1" + # Batch documentation requires this runtime commit until its next release. + SUBMILLI_RUNTIME_REF: "1f1377033809fe459354f7ec8f1787cd1fe5e845" jobs: lint: @@ -77,20 +77,22 @@ jobs: steps: - uses: actions/checkout@v7 - uses: astral-sh/setup-uv@v10.3.0 - - name: Download submilli-server ${{ env.SUBMILLI_VERSION }} - run: | - mkdir -p .submilli-bin && cd .submilli-bin - base=https://github.com/submilli/submilli-runtime/releases/download/v${SUBMILLI_VERSION} - curl -fsSLO "$base/submilli-server-x86_64-unknown-linux-musl" - curl -fsSLO "$base/SHA256SUMS" - grep ' submilli-server-x86_64-unknown-linux-musl$' SHA256SUMS | sha256sum -c - mv submilli-server-x86_64-unknown-linux-musl submilli-server - chmod +x submilli-server + - uses: actions/checkout@v7 + with: + repository: submilli/submilli-runtime + ref: ${{ env.SUBMILLI_RUNTIME_REF }} + path: runtime + - uses: dtolnay/rust-toolchain@1.98.1 + - name: Build the runtime with batch documentation + run: cargo build --locked --release -p submilli-server + working-directory: runtime + env: + CARGO_BUILD_JOBS: "2" - run: uv sync --all-packages --all-groups --locked - run: uv run pytest ${{ matrix.tests }} -m 'not durable' working-directory: packages/${{ matrix.package }} env: - SUBMILLI_BIN: ${{ github.workspace }}/.submilli-bin + SUBMILLI_BIN: ${{ github.workspace }}/runtime/target/release durable-integration: runs-on: ubuntu-latest diff --git a/packages/langchain-submilli/CHANGELOG.md b/packages/langchain-submilli/CHANGELOG.md index e80a788..92c6ae9 100644 --- a/packages/langchain-submilli/CHANGELOG.md +++ b/packages/langchain-submilli/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- SUB-2074: Fetch package documentation in one ordered batch. The SDK adds `package_docs_batch(id, names)` on blueprints and sessions; `package_docs(id, name)` retains its existing GET endpoint and behavior. Agent documentation tools now take `names: string[]`. Batch calls require a server with SUB-2074 support. + + 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/). diff --git a/packages/langchain-submilli/examples/research/note.ts b/packages/langchain-submilli/examples/research/note.ts index 36bba4b..31166c8 100644 --- a/packages/langchain-submilli/examples/research/note.ts +++ b/packages/langchain-submilli/examples/research/note.ts @@ -2,7 +2,7 @@ import fs from "submilli:fs"; function main(): string { const before: string[] = []; - for (const entry of fs.list(".", false)) before.push(entry.name); + for (const entry of fs.list(".")) before.push(entry.name); fs.writeText("check.md", "# Written by a check\n"); return `notes before: ${before.join(", ") || "none"}`; } diff --git a/packages/langchain-submilli/src/langchain_submilli/_app_tools.py b/packages/langchain-submilli/src/langchain_submilli/_app_tools.py index 3790c28..f004763 100644 --- a/packages/langchain-submilli/src/langchain_submilli/_app_tools.py +++ b/packages/langchain-submilli/src/langchain_submilli/_app_tools.py @@ -304,7 +304,7 @@ def prompt(self) -> str: "\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")`.' + '`submilli__typescript__packages__docs({"names":["@tools"]})`.' ) async def answer(self, call: PendingCall, runtime: ToolRuntime[Any, Any], used: list[int]) -> CallOutcome: diff --git a/packages/langchain-submilli/src/langchain_submilli/_middleware.py b/packages/langchain-submilli/src/langchain_submilli/_middleware.py index d29502a..0aba326 100644 --- a/packages/langchain-submilli/src/langchain_submilli/_middleware.py +++ b/packages/langchain-submilli/src/langchain_submilli/_middleware.py @@ -364,12 +364,26 @@ async def search(session_id: str) -> str: return await self._in_session(t.PACKAGES_SEARCH, runtime, search) - async def _packages_docs(self, name: str, runtime: ToolRuntime) -> str | ToolMessage | Command[Any]: + async def _packages_docs(self, names: list[str], runtime: ToolRuntime) -> str | ToolMessage | Command[Any]: if not self._exposed: - return (await self.client.blueprints.package_docs(self.blueprint, name)).text + return t.as_text( + { + "results": [ + dict(name=e.name, found=e.found, text=e.text) + for e in await self.client.blueprints.package_docs_batch(self.blueprint, names) + ] + } + ) async def docs(session_id: str) -> str: - return (await self.client.sessions.package_docs(session_id, name)).text + return t.as_text( + { + "results": [ + dict(name=e.name, found=e.found, text=e.text) + for e in await self.client.sessions.package_docs_batch(session_id, names) + ] + } + ) return await self._in_session(t.PACKAGES_DOCS, runtime, docs) diff --git a/packages/langchain-submilli/src/langchain_submilli/_tools.py b/packages/langchain-submilli/src/langchain_submilli/_tools.py index f2ccb95..88c59a4 100644 --- a/packages/langchain-submilli/src/langchain_submilli/_tools.py +++ b/packages/langchain-submilli/src/langchain_submilli/_tools.py @@ -52,7 +52,7 @@ class PackagesSearchArgs(BaseModel): class PackagesDocsArgs(BaseModel): - name: str = Field(description="Package name, e.g. `submilli:crypto`.") + names: list[str] = Field(description="Package names to read before writing a program.") class BuiltinsDocsArgs(BaseModel): diff --git a/packages/langchain-submilli/tests/integration_tests/test_app_tools.py b/packages/langchain-submilli/tests/integration_tests/test_app_tools.py index 2ddb43d..1e325f0 100644 --- a/packages/langchain-submilli/tests/integration_tests/test_app_tools.py +++ b/packages/langchain-submilli/tests/integration_tests/test_app_tools.py @@ -130,7 +130,7 @@ async def test_a_program_calls_a_typed_tool_and_uses_its_fields(managed: Managed async def test_package_docs_show_the_tools_signatures(managed: ManagedServer) -> None: - graph = agent(managed, [call("submilli__typescript__packages__docs", {"name": "@tools"})], "done") + graph = agent(managed, [call("submilli__typescript__packages__docs", {"names": ["@tools"]})], "done") text = first_tool(await start(graph)).content assert "function query_orders(args: { customer: string }): { count: number; for_user: number };" in text, text diff --git a/packages/langchain-submilli/tests/integration_tests/test_research.py b/packages/langchain-submilli/tests/integration_tests/test_research.py index 152d297..8b61dde 100644 --- a/packages/langchain-submilli/tests/integration_tests/test_research.py +++ b/packages/langchain-submilli/tests/integration_tests/test_research.py @@ -180,7 +180,7 @@ async def test_discovery_tools(submilli: SubmilliMiddleware) -> None: model = scripted( [ call("submilli__typescript__packages__search", {"query": "jina"}), - call("submilli__typescript__packages__docs", {"name": "@submilli/jina"}), + call("submilli__typescript__packages__docs", {"names": ["@submilli/jina"]}), call("submilli__typescript__builtins__list", {}), call("submilli__typescript__builtins__docs", {"names": ["Map", "Temporal.Instant"]}), ], diff --git a/packages/langchain-submilli/tests/unit_tests/fake_server.py b/packages/langchain-submilli/tests/unit_tests/fake_server.py index 2c5e73a..f72bfdd 100644 --- a/packages/langchain-submilli/tests/unit_tests/fake_server.py +++ b/packages/langchain-submilli/tests/unit_tests/fake_server.py @@ -86,6 +86,27 @@ def handle(self, request: httpx.Request) -> httpx.Response: 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 == "GET" + and "name" not in request.url.params + and re.fullmatch(r"/v1/(sessions|blueprints)/[^/]+/packages/docs", path) + ): + scope = httpx.Request( + "GET", + request.url.copy_with(query=None).copy_set_param("name", "@submilli/jina"), + headers=request.headers, + ) + exists = self.handle(scope) + if exists.status_code != 200: + return exists + results = [] + for name in request.url.params.get_list("names[]"): + single = httpx.Request( + "GET", request.url.copy_with(query=None).copy_set_param("name", name), headers=request.headers + ) + response = self.handle(single) + results.append({"name": name, "found": response.status_code == 200, "text": response.text}) + return httpx.Response(200, json={"results": results}) if method == "POST" and path == "/v1/sessions": return self.open(body) if match := re.fullmatch(r"/v1/sessions/([^/]+)/execute", path): diff --git a/packages/langchain-submilli/tests/unit_tests/test_app_tools.py b/packages/langchain-submilli/tests/unit_tests/test_app_tools.py index 10f94ff..b7185e6 100644 --- a/packages/langchain-submilli/tests/unit_tests/test_app_tools.py +++ b/packages/langchain-submilli/tests/unit_tests/test_app_tools.py @@ -186,6 +186,7 @@ async def test_the_execute_description_tells_the_model_about_tools(server: FakeS [described] = [tool for tool in model.bound[-1] if tool.name == "submilli__typescript__execute"] assert '`import tools from "@tools";`' in described.description assert "`query_orders`, `send_email`" in described.description + assert 'packages__docs({"names":["@tools"]})' in described.description assert "approval" in described.description # Exposed tools are for programs; the model is not given them. assert "query_orders" not in model.tool_names() @@ -346,7 +347,7 @@ async def test_a_changed_tool_catalog_opens_a_new_session(server: FakeServer) -> async def test_package_docs_include_the_sessions_tools(server: FakeServer) -> None: - model = calls_then_done(call("submilli__typescript__packages__docs", {"name": "@tools"})) + model = calls_then_done(call("submilli__typescript__packages__docs", {"names": ["@tools"]})) result = await start(agent(model, middleware(ptc=[query_orders]))) assert server.calls("GET", r"/v1/sessions/.*/packages/docs") diff --git a/packages/langchain-submilli/tests/unit_tests/test_middleware.py b/packages/langchain-submilli/tests/unit_tests/test_middleware.py index 7dd0ba5..2385f16 100644 --- a/packages/langchain-submilli/tests/unit_tests/test_middleware.py +++ b/packages/langchain-submilli/tests/unit_tests/test_middleware.py @@ -127,8 +127,8 @@ async def test_different_variables_on_a_thread_are_refused(server: FakeServer) - 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__packages__docs", {"name": "nope"}), + call("submilli__typescript__packages__docs", {"names": ["@submilli/jina"]}), + call("submilli__typescript__packages__docs", {"names": ["nope"]}), call("submilli__typescript__builtins__list", {}), call("submilli__typescript__builtins__docs", {"names": ["Map", "JSON"]}), call("submilli__typescript__last_run", {}), @@ -138,7 +138,7 @@ async def test_discovery_tools_do_not_open_a_session(server: FakeServer) -> None outputs = [m.content for m in result["messages"] if m.type == "tool"] assert json.loads(outputs[0])["results"][0]["name"] == "@submilli/jina" assert "searchJson" in outputs[1] - assert json.loads(outputs[2])["error"] == "unknown_package" + assert json.loads(outputs[2])["results"][0]["found"] is False assert json.loads(outputs[3])["types"] == ["Map"] assert [r["name"] for r in json.loads(outputs[4])["results"]] == ["Map", "JSON"] assert outputs[5] == "No program has run in this thread yet." diff --git a/packages/langchain-submilli/tests/unit_tests/test_standard.py b/packages/langchain-submilli/tests/unit_tests/test_standard.py index 3a174c9..c20b8f3 100644 --- a/packages/langchain-submilli/tests/unit_tests/test_standard.py +++ b/packages/langchain-submilli/tests/unit_tests/test_standard.py @@ -48,7 +48,7 @@ class TestPackagesSearch(_Base): class TestPackagesDocs(_Base): name = "submilli__typescript__packages__docs" - example: ClassVar[dict[str, Any]] = {"name": "@submilli/jina"} + example: ClassVar[dict[str, Any]] = {"names": ["@submilli/jina"]} class TestBuiltinsList(_Base): diff --git a/packages/openai-agents-submilli/CHANGELOG.md b/packages/openai-agents-submilli/CHANGELOG.md index 9606b86..b6d45a5 100644 --- a/packages/openai-agents-submilli/CHANGELOG.md +++ b/packages/openai-agents-submilli/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- SUB-2074: Fetch package documentation in one ordered batch. The SDK adds `package_docs_batch(id, names)` on blueprints and sessions; `package_docs(id, name)` retains its existing GET endpoint and behavior. Agent documentation tools now take `names: string[]`. Batch calls require a server with SUB-2074 support. + + 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/). 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 index c9e543e..ee704f6 100644 --- a/packages/openai-agents-submilli/src/openai_agents_submilli/_app_tools.py +++ b/packages/openai-agents-submilli/src/openai_agents_submilli/_app_tools.py @@ -135,7 +135,7 @@ def prompt(self) -> str: "\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")`.' + '`submilli__typescript__packages__docs({"names":["@tools"]})`.' ) 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 b578a54..b0dab3b 100644 --- a/packages/openai-agents-submilli/src/openai_agents_submilli/_toolkit.py +++ b/packages/openai-agents-submilli/src/openai_agents_submilli/_toolkit.py @@ -333,12 +333,26 @@ async def search(session_id: str) -> str: return await self._in_session(context, search) async def _packages_docs(self, context: ToolContext[Any], arguments: dict[str, Any]) -> str: - name = str(arguments["name"]) + names = arguments["names"] if not self._program_tools: - return (await self.client.blueprints.package_docs(self.blueprint, name)).text + return t.as_text( + { + "results": [ + dict(name=e.name, found=e.found, text=e.text) + for e in await self.client.blueprints.package_docs_batch(self.blueprint, names) + ] + } + ) async def docs(session_id: str) -> str: - return (await self.client.sessions.package_docs(session_id, name)).text + return t.as_text( + { + "results": [ + dict(name=e.name, found=e.found, text=e.text) + for e in await self.client.sessions.package_docs_batch(session_id, names) + ] + } + ) return await self._in_session(context, docs) 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 190c3ad..22113ff 100644 --- a/packages/openai-agents-submilli/src/openai_agents_submilli/_tools.py +++ b/packages/openai-agents-submilli/src/openai_agents_submilli/_tools.py @@ -52,7 +52,15 @@ def _object(properties: dict[str, Any]) -> dict[str, Any]: } } ) -PACKAGES_DOCS_PARAMS = _object({"name": {"type": "string", "description": "Package name, e.g. `submilli:crypto`."}}) +PACKAGES_DOCS_PARAMS = _object( + { + "names": { + "type": "array", + "items": {"type": "string"}, + "description": "Package names to read before writing a program.", + } + } +) BUILTINS_DOCS_PARAMS = _object( { "names": { 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 a0ed863..187f7db 100644 --- a/packages/openai-agents-submilli/tests/integration_tests/test_server.py +++ b/packages/openai-agents-submilli/tests/integration_tests/test_server.py @@ -120,7 +120,7 @@ async def test_a_program_calls_a_typed_tool_with_the_runs_context(managed: Manag async def test_package_docs_show_the_tools_signatures(managed: ManagedServer) -> None: - model = calls_then_done(call("submilli__typescript__packages__docs", {"name": "@tools"})) + model = calls_then_done(call("submilli__typescript__packages__docs", {"names": ["@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 diff --git a/packages/openai-agents-submilli/tests/unit_tests/fake_server.py b/packages/openai-agents-submilli/tests/unit_tests/fake_server.py index 2c5e73a..f72bfdd 100644 --- a/packages/openai-agents-submilli/tests/unit_tests/fake_server.py +++ b/packages/openai-agents-submilli/tests/unit_tests/fake_server.py @@ -86,6 +86,27 @@ def handle(self, request: httpx.Request) -> httpx.Response: 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 == "GET" + and "name" not in request.url.params + and re.fullmatch(r"/v1/(sessions|blueprints)/[^/]+/packages/docs", path) + ): + scope = httpx.Request( + "GET", + request.url.copy_with(query=None).copy_set_param("name", "@submilli/jina"), + headers=request.headers, + ) + exists = self.handle(scope) + if exists.status_code != 200: + return exists + results = [] + for name in request.url.params.get_list("names[]"): + single = httpx.Request( + "GET", request.url.copy_with(query=None).copy_set_param("name", name), headers=request.headers + ) + response = self.handle(single) + results.append({"name": name, "found": response.status_code == 200, "text": response.text}) + return httpx.Response(200, json={"results": results}) if method == "POST" and path == "/v1/sessions": return self.open(body) if match := re.fullmatch(r"/v1/sessions/([^/]+)/execute", path): 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 31156ec..450b83c 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 @@ -137,6 +137,7 @@ async def test_a_program_calls_the_applications_tools(server: FakeServer) -> Non # 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 'packages__docs({"names":["@tools"]})' in described["submilli__typescript__execute"] assert "lookup_order" not in described @@ -217,7 +218,7 @@ async def test_two_approvals_resume_from_a_serialized_state(server: FakeServer) 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"}), + call("submilli__typescript__packages__docs", {"names": ["@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)] 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 ab519e1..ac7317f 100644 --- a/packages/openai-agents-submilli/tests/unit_tests/test_toolkit.py +++ b/packages/openai-agents-submilli/tests/unit_tests/test_toolkit.py @@ -140,7 +140,7 @@ class Context: 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__packages__docs", {"names": ["@submilli/jina"]}), call("submilli__typescript__builtins__list", {}), call("submilli__typescript__builtins__docs", {"names": ["Map"]}), call("submilli__typescript__last_run", {}), @@ -148,7 +148,7 @@ async def test_discovery_tools_do_not_open_a_session(server: FakeServer) -> None 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 json.loads(texts[1])["results"][0]["text"].startswith("# jina") assert texts[4] == "No program has run in this conversation yet." assert server.sessions == {} diff --git a/packages/submilli/CHANGELOG.md b/packages/submilli/CHANGELOG.md index 48508af..8b66e07 100644 --- a/packages/submilli/CHANGELOG.md +++ b/packages/submilli/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +Release coordination: publish the updated core SDK before its integrations and raise their SDK dependency minimums to that release. These PRs do not publish packages. + +- SUB-2074: Fetch package documentation in one ordered batch. The SDK adds `package_docs_batch(id, names)` on blueprints and sessions; `package_docs(id, name)` retains its existing GET endpoint and behavior. Agent documentation tools now take `names: string[]`. Batch calls require a server with SUB-2074 support. + + 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/). diff --git a/packages/submilli/README.md b/packages/submilli/README.md index 4c21732..dfdbae6 100644 --- a/packages/submilli/README.md +++ b/packages/submilli/README.md @@ -194,6 +194,13 @@ await client.blueprints.builtin_docs("research", ["Map", "Temporal"]) `sessions.search_packages` and `sessions.package_docs` answer the same for one session, including its `@tools`. +### Batch package documentation + +Call `package_docs_batch(id, names)` on `client.blueprints` or `client.sessions` to fetch several modules in one request. Batch requests use GET with repeated `names[]` query parameters. Results retain input order and duplicates; each entry has `name`, `found`, and `text`. An unknown package has `found: false`, while an unknown blueprint or session fails the request. An empty list returns an empty list. + +The existing `package_docs(id, name)` API remains available and continues using the original GET route. Batch methods and the agent integrations require a server with SUB-2074 support; there is no fallback to individual requests. + + ## Errors Every error is a `SubmilliError`: diff --git a/packages/submilli/src/submilli/__init__.py b/packages/submilli/src/submilli/__init__.py index 93d81a4..7306315 100644 --- a/packages/submilli/src/submilli/__init__.py +++ b/packages/submilli/src/submilli/__init__.py @@ -39,6 +39,7 @@ ExecuteResult, LastRun, PackageDocs, + PackageDocsEntry, PendingCall, RunError, RunningRun, @@ -72,6 +73,7 @@ "LastRun", "NotFoundError", "PackageDocs", + "PackageDocsEntry", "PendingCall", "RunError", "RunNotSuspendedError", diff --git a/packages/submilli/src/submilli/_async_client.py b/packages/submilli/src/submilli/_async_client.py index 8318bdf..8fd2a25 100644 --- a/packages/submilli/src/submilli/_async_client.py +++ b/packages/submilli/src/submilli/_async_client.py @@ -24,6 +24,7 @@ ExecuteResult, LastRun, PackageDocs, + PackageDocsEntry, PendingCall, RunningRun, RunState, @@ -216,6 +217,10 @@ async def package_docs(self, session_id: str, name: str) -> PackageDocs: """A Package's docs, where `@tools` is the session's own.""" return b.read_package_docs(await self._client._send(b.session_packages_docs(session_id, name))) + async def package_docs_batch(self, session_id: str, names: Sequence[str]) -> list[PackageDocsEntry]: + """Read several packages in one request. Requires server batch-docs support.""" + return b.read_package_docs_batch(await self._client._send(b.packages_docs_batch("sessions", session_id, names))) + class AsyncRuns: """Runs that wait on the application's tools.""" @@ -312,6 +317,12 @@ async def package_docs(self, blueprint: str, name: str) -> PackageDocs: """`GET /v1/blueprints/{name}/packages/docs`.""" return b.read_package_docs(await self._client._send(b.packages_docs(blueprint, name))) + async def package_docs_batch(self, blueprint: str, names: Sequence[str]) -> list[PackageDocsEntry]: + """Read several packages in one request. Requires server batch-docs support.""" + return b.read_package_docs_batch( + await self._client._send(b.packages_docs_batch("blueprints", blueprint, names)) + ) + async def builtins(self, blueprint: str) -> dict[str, Any]: """`GET /v1/blueprints/{name}/builtins`: the built-ins in scope without an import.""" return b.read_json(await self._client._send(b.builtins(blueprint))) diff --git a/packages/submilli/src/submilli/_base.py b/packages/submilli/src/submilli/_base.py index f4120d7..63194ff 100644 --- a/packages/submilli/src/submilli/_base.py +++ b/packages/submilli/src/submilli/_base.py @@ -23,6 +23,7 @@ ExecuteResult, LastRun, PackageDocs, + PackageDocsEntry, RunState, ToolDeclaration, ToolResult, @@ -280,3 +281,29 @@ def execute_once( def read_execute_result(response: httpx.Response) -> ExecuteResult: return ExecuteResult._from_json(_json(response)) + + +def packages_docs_batch(scope: str, identifier: str, names: Sequence[str]) -> Request: + return Request( + "GET", + f"/v1/{scope}/{_seg(identifier)}/packages/docs", + params=[("names[]", name) for name in names], + retryable=True, + ) + + +def read_package_docs_batch(response: httpx.Response) -> list[PackageDocsEntry]: + results = read_json(response).get("results") + if not isinstance(results, list): + raise TypeError("expected docs results array") + entries = [] + for entry in results: + if ( + not isinstance(entry, dict) + or not isinstance(entry.get("name"), str) + or not isinstance(entry.get("found"), bool) + or not isinstance(entry.get("text"), str) + ): + raise TypeError("invalid batch docs entry") + entries.append(PackageDocsEntry(name=entry["name"], found=entry["found"], text=entry["text"])) + return entries diff --git a/packages/submilli/src/submilli/_client.py b/packages/submilli/src/submilli/_client.py index 2572028..425a11b 100644 --- a/packages/submilli/src/submilli/_client.py +++ b/packages/submilli/src/submilli/_client.py @@ -24,6 +24,7 @@ ExecuteResult, LastRun, PackageDocs, + PackageDocsEntry, PendingCall, RunningRun, RunState, @@ -207,6 +208,10 @@ def package_docs(self, session_id: str, name: str) -> PackageDocs: """A Package's docs, where `@tools` is the session's own.""" return b.read_package_docs(self._client._send(b.session_packages_docs(session_id, name))) + def package_docs_batch(self, session_id: str, names: Sequence[str]) -> list[PackageDocsEntry]: + """Read several packages in one request. Requires server batch-docs support.""" + return b.read_package_docs_batch(self._client._send(b.packages_docs_batch("sessions", session_id, names))) + class Runs: """Runs that wait on the application's tools.""" @@ -301,6 +306,10 @@ def package_docs(self, blueprint: str, name: str) -> PackageDocs: """`GET /v1/blueprints/{name}/packages/docs`.""" return b.read_package_docs(self._client._send(b.packages_docs(blueprint, name))) + def package_docs_batch(self, blueprint: str, names: Sequence[str]) -> list[PackageDocsEntry]: + """Read several packages in one request. Requires server batch-docs support.""" + return b.read_package_docs_batch(self._client._send(b.packages_docs_batch("blueprints", blueprint, names))) + def builtins(self, blueprint: str) -> dict[str, Any]: """`GET /v1/blueprints/{name}/builtins`: the built-ins in scope without an import.""" return b.read_json(self._client._send(b.builtins(blueprint))) diff --git a/packages/submilli/src/submilli/_types.py b/packages/submilli/src/submilli/_types.py index 1c06d4c..9080edf 100644 --- a/packages/submilli/src/submilli/_types.py +++ b/packages/submilli/src/submilli/_types.py @@ -13,6 +13,7 @@ "ExecuteResult", "LastRun", "PackageDocs", + "PackageDocsEntry", "PendingCall", "RunError", "RunState", @@ -255,6 +256,13 @@ class PackageDocs: text: str +@dataclass(frozen=True, slots=True) +class PackageDocsEntry(PackageDocs): + """One entry in a batch docs response, in request order.""" + + name: str + + @dataclass(frozen=True, slots=True) class BlueprintPrompt: """`GET /v1/blueprints/{name}/prompt`: what the server writes for a model about this Blueprint.""" diff --git a/packages/submilli/tests/integration/test_server.py b/packages/submilli/tests/integration/test_server.py index b090212..b0ad10e 100644 --- a/packages/submilli/tests/integration/test_server.py +++ b/packages/submilli/tests/integration/test_server.py @@ -127,6 +127,13 @@ def answer(call: PendingCall) -> ToolResult: docs = await client.sessions.package_docs(session.id, "@tools") assert docs.found and "query_orders(args: { customer: string }): { count: number }" in docs.text + batch = await client.sessions.package_docs_batch(session.id, ["@tools", "not-a-package", "@tools"]) + assert [(entry.name, entry.found) for entry in batch] == [ + ("@tools", True), + ("not-a-package", False), + ("@tools", True), + ] + assert batch[0].text == docs.text and batch[0] == batch[2] async def test_the_blueprint_refuses_tools_it_does_not_grant(client: AsyncSubmilli) -> None: diff --git a/packages/submilli/tests/unit/fake_server.py b/packages/submilli/tests/unit/fake_server.py index 56adc70..604fbe9 100644 --- a/packages/submilli/tests/unit/fake_server.py +++ b/packages/submilli/tests/unit/fake_server.py @@ -82,6 +82,27 @@ def handle(self, request: httpx.Request) -> httpx.Response: 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 == "GET" + and "name" not in request.url.params + and re.fullmatch(r"/v1/(sessions|blueprints)/[^/]+/packages/docs", path) + ): + scope = httpx.Request( + "GET", + request.url.copy_with(query=None).copy_set_param("name", "@submilli/jina"), + headers=request.headers, + ) + exists = self.handle(scope) + if exists.status_code != 200: + return exists + results = [] + for name in request.url.params.get_list("names[]"): + single = httpx.Request( + "GET", request.url.copy_with(query=None).copy_set_param("name", name), headers=request.headers + ) + response = self.handle(single) + results.append({"name": name, "found": response.status_code == 200, "text": response.text}) + return httpx.Response(200, json={"results": results}) if method == "POST" and path == "/v1/sessions": return self.open(body) if method == "POST" and path == "/v1/execute": diff --git a/packages/submilli/tests/unit/test_client.py b/packages/submilli/tests/unit/test_client.py index 41b729a..6b2be4c 100644 --- a/packages/submilli/tests/unit/test_client.py +++ b/packages/submilli/tests/unit/test_client.py @@ -225,3 +225,34 @@ def test_inline_refs_resolves_local_definitions_and_stops_at_recursion() -> None "node": {"type": "object", "properties": {"next": {}}}, }, } + + +async def test_batch_docs_keep_order_duplicates_missing_and_legacy(server: FakeServer, client: AsyncSubmilli) -> None: + names = ["@submilli/jina", "@nope/x", "@submilli/jina"] + entries = await client.blueprints.package_docs_batch("research", names) + assert [entry.name for entry in entries] == names + assert [entry.found for entry in entries] == [True, False, True] + assert entries[0].text == (await client.blueprints.package_docs("research", names[0])).text + assert await client.blueprints.package_docs_batch("research", []) == [] + with pytest.raises(UnknownBlueprintError): + await client.blueprints.package_docs_batch("nope", []) + session = await client.sessions.create("research", variables={"userId": "u"}) + assert await client.sessions.package_docs_batch(session.id, names) == entries + with pytest.raises(UnknownSessionError): + await client.sessions.package_docs_batch("missing", names) + requests = [ + request + for request in server.requests + if request.method == "GET" and request.url.path.endswith("packages/docs") and "names[]" in request.url.params + ] + assert requests[0].url.params.get_list("names[]") == names + assert requests[0].content == b"" + special = "@scope/a+b & c/δ" + assert (await client.blueprints.package_docs_batch("research", [special]))[0].name == special + + +def test_sync_batch_docs_preserve_legacy(server: FakeServer) -> None: + with Submilli(base_url=SERVER, token=TOKEN) as client: + docs = client.blueprints.package_docs_batch("research", ["@submilli/jina", "@nope/x"]) + assert [entry.found for entry in docs] == [True, False] + assert docs[0].text == client.blueprints.package_docs("research", "@submilli/jina").text