diff --git a/CHANGELOG.md b/CHANGELOG.md index 34e523ccf..3a43aee3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,35 @@ Two things are versioned separately from this file and worth knowing about: ## [Unreleased] +### Changed + +- **Agents work in Pydantic AI workspaces.** The sandbox, attachments, skills, + artifacts, generated images and spilled tool results all reach an agent's + files through the run's workspace, on Pydantic AI 2.54 and + `pydantic-ai-backend` 0.2.32. The runner hands the run the workspace it + opened, so a conversation that continues on another host, or in another + environment, works in the workspace it has now. +- **A sandbox whose files were purged is reported, not replaced.** Once a + conversation's container or Daytona sandbox has been opened, later turns attach + to that session; if its files were swept on the host, the agent is told they are + gone instead of carrying on in an empty one, and the next turn starts afresh. +- **The activity log names what reached the sandbox.** Operations are `read`, + `write`, `ls_info`, `mkdir`, `remove` and `execute`: an `edit_file` shows as a + `read` and a `write`, a `glob` or `grep` as the command it ran. Rows recorded + before keep their old names (`edit`, `glob_info`, `grep_raw`, `read_bytes`) + until retention removes them. +- **A Daytona connection can open a sandbox.** The Daytona SDK was never + installed, so the old backend's lazy import failed on every open; the backend + image now carries it, and a Daytona sandbox found broken (`error`, + `build_failed`) is deleted so the next turn can open its name afresh. +- **Budgets and reminders keep their place around compaction.** Pydantic AI 2.54 + runs every `wrap_*` hook outside every `before_*` hook, so the budget is now + checked again after a compaction summary was paid for, and a system reminder is + appended after compaction rather than before it, where a summary dropped it. +- **An Anthropic agent with no `max_tokens` can answer at length.** Pydantic AI + now defaults it to the model's maximum output rather than 4,096 tokens. Set + `max_tokens` on the agent or its model profile to keep a ceiling. + ## [0.0.528] - 2026-10-08 ### Fixed diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index c1260b779..220070d9d 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -23,16 +23,16 @@ names no author is in the evidence column below. | Licence | Backend | Frontend | |---|---:|---:| -| MIT | 113 | 241 | -| Apache-2.0 | 61 | 24 | +| MIT | 121 | 241 | +| Apache-2.0 | 70 | 24 | | BSD-3-Clause | 33 | 5 | | ISC | 6 | 16 | | BSD-2-Clause | 10 | 0 | +| MPL-2.0 | 4 | 0 | | LGPL-3.0-or-later | 1 | 2 | -| MPL-2.0 | 3 | 0 | | PSF-2.0 | 3 | 0 | +| 0BSD | 1 | 1 | | Apache-2.0 AND MIT | 0 | 2 | -| 0BSD | 0 | 1 | | AGPL-3.0-only | 1 | 0 | | Apache-2.0 AND BSD-2-Clause | 1 | 0 | | Apache-2.0 AND CNRI-Python | 1 | 0 | @@ -52,7 +52,7 @@ names no author is in the evidence column below. ## Backend image (Python) -244 distributions. +263 distributions. | Component | Version | Licence | Source | Evidence | |---|---|---|---|---| @@ -61,6 +61,7 @@ names no author is in the evidence column below. | aiogram | 3.31.0 | MIT | https://github.com/aiogram/aiogram/ | License-Expression | | aiohappyeyeballs | 2.7.1 | PSF-2.0 | https://github.com/aio-libs/aiohappyeyeballs | License field | | aiohttp | 3.14.3 | Apache-2.0 | https://github.com/aio-libs/aiohttp | licence file text | +| aiohttp-retry | 2.9.1 | MIT | https://github.com/inyutin/aiohttp_retry | License field | | aiosignal | 1.4.0 | Apache-2.0 | https://github.com/aio-libs/aiosignal | License field | | aiosmtplib | 5.1.3 | MIT | https://github.com/cole/aiosmtplib | License field | | aiosqlite | 0.22.1 | MIT | https://github.com/omnilib/aiosqlite | classifier | @@ -68,7 +69,7 @@ names no author is in the evidence column below. | amplitude-analytics | 1.2.3 | MIT | https://github.com/amplitude/Amplitude-Python | License field | | annotated-doc | 0.0.5 | MIT | https://github.com/fastapi/annotated-doc | License-Expression | | annotated-types | 0.8.0 | MIT | https://github.com/annotated-types/annotated-types | License-Expression | -| anthropic | 1.6.0 | MIT | https://github.com/anthropics/anthropic-sdk-python | License field | +| anthropic | 1.11.0 | MIT | https://github.com/anthropics/anthropic-sdk-python | License field | | anyio | 4.15.1 | MIT | https://github.com/agronholm/anyio | License-Expression | | apprise | 1.13.1 | BSD-2-Clause | https://github.com/caronc/apprise | License field | | asgi-lifespan | 2.1.0 | MIT | https://github.com/florimondmanca/asgi-lifespan | License field | @@ -80,6 +81,7 @@ names no author is in the evidence column below. | bcrypt | 5.0.0 | Apache-2.0 | https://github.com/pyca/bcrypt/ | License field | | beartype | 0.22.9 | MIT | https://github.com/beartype/beartype | License field | | beautifulsoup4 | 4.15.0 | MIT | https://www.crummy.com/software/BeautifulSoup/bs4/ | License field | +| bidict | 0.24.1 | MPL-2.0 | https://github.com/jab/bidict | License-Expression; review accepted | | boto3 | 1.43.102 | Apache-2.0 | https://github.com/boto/boto3 | License field | | botocore | 1.43.103 | Apache-2.0 | https://github.com/boto/botocore | License field | | bracex | 3.0.1 | MIT | https://github.com/facelessuser/bracex | License-Expression | @@ -88,6 +90,7 @@ names no author is in the evidence column below. | caio | 0.12.4 | Apache-2.0 | https://github.com/mosquito/caio/ | License-Expression | | certifi | 2026.7.22 | MPL-2.0 | https://github.com/certifi/python-certifi | License field; review accepted | | cffi | 2.1.1 | MIT-0 | https://github.com/python-cffi/cffi | License-Expression | +| chardet | 7.6.0 | 0BSD | https://github.com/chardet/chardet | License-Expression | | charset-normalizer | 3.5.1 | MIT | https://charset-normalizer.readthedocs.io/ | License field | | click | 8.5.0 | BSD-3-Clause | https://github.com/pallets/click/ | License-Expression | | cloudpickle | 3.1.2 | BSD-3-Clause | https://github.com/cloudpipe/cloudpickle | License field | @@ -99,8 +102,16 @@ names no author is in the evidence column below. | cryptography | 50.0.1 | Apache-2.0 OR BSD-3-Clause | https://github.com/pyca/cryptography/ | License-Expression | | cyclopts | 4.25.2 | Apache-2.0 | https://github.com/BrianPugh/cyclopts | License-Expression | | dateparser | 1.4.3 | BSD-3-Clause | https://github.com/scrapinghub/dateparser | License-Expression | +| daytona | 0.220.0 | Apache-2.0 | https://pypi.org/project/daytona/0.220.0/ | License field | +| daytona-analytics-api-client | 0.220.0 | Apache-2.0 | https://github.com/daytona/clients | licence file text | +| daytona-analytics-api-client-async | 0.220.0 | Apache-2.0 | https://github.com/daytona/clients | licence file text | +| daytona-api-client | 0.220.0 | Apache-2.0 | https://github.com/daytona/clients | License field | +| daytona-api-client-async | 0.220.0 | Apache-2.0 | https://github.com/daytona/clients | License field | +| daytona-toolbox-api-client | 0.220.0 | Apache-2.0 | https://github.com/daytona/clients | License field | +| daytona-toolbox-api-client-async | 0.220.0 | Apache-2.0 | https://github.com/daytona/clients | License field | | ddgs | 9.16.0 | MIT | https://github.com/deedy5/ddgs | License-Expression | | defusedxml | 0.7.1 | PSF-2.0 | https://github.com/tiran/defusedxml | classifier | +| deprecated | 1.3.1 | MIT | https://github.com/laurent-laporte-pro/deprecated | License field | | distro | 1.9.0 | Apache-2.0 | https://github.com/python-distro/distro | License field | | dnspython | 2.8.0 | ISC | https://github.com/rthalley/dnspython.git | License field | | docker | 7.2.0 | Apache-2.0 | https://github.com/docker/docker-py | License-Expression | @@ -116,12 +127,12 @@ names no author is in the evidence column below. | filelock | 3.32.6 | MIT | https://github.com/tox-dev/py-filelock | License-Expression | | frozenlist | 1.8.0 | Apache-2.0 | https://github.com/aio-libs/frozenlist | License field | | fsspec | 2026.7.0 | BSD-3-Clause | https://github.com/fsspec/filesystem_spec | License-Expression | -| genai-prices | 0.1.7 | MIT | https://github.com/pydantic/genai-prices | License-Expression | +| genai-prices | 0.1.9 | MIT | https://github.com/pydantic/genai-prices | License-Expression | | google-api-core | 2.37.0 | Apache-2.0 | https://github.com/googleapis/google-cloud-python | License field | | google-api-python-client | 2.200.0 | Apache-2.0 | https://github.com/googleapis/google-api-python-client/ | License field | | google-auth | 2.58.1 | Apache-2.0 | https://github.com/googleapis/google-cloud-python/tree/main/packages/google-auth | License field | | google-auth-httplib2 | 0.4.2 | Apache-2.0 | https://github.com/googleapis/google-cloud-python/packages/google-auth-httplib2 | License field | -| google-genai | 2.23.0 | Apache-2.0 | https://github.com/googleapis/python-genai | License-Expression | +| google-genai | 2.28.0 | Apache-2.0 | https://github.com/googleapis/python-genai | License-Expression | | googleapis-common-protos | 1.75.3 | Apache-2.0 | https://github.com/googleapis/google-cloud-python/tree/main/packages/googleapis-common-protos | License-Expression | | graphviz | 0.21 | MIT | https://github.com/xflr6/graphviz | License-Expression | | greenlet | 3.5.6 | MIT AND PSF-2.0 | https://github.com/python-greenlet/greenlet | License-Expression | @@ -139,6 +150,7 @@ names no author is in the evidence column below. | httplib2 | 0.32.0 | MIT | https://github.com/httplib2/httplib2 | License field | | httptools | 0.8.0 | MIT | https://github.com/MagicStack/httptools | License-Expression | | httpx | 0.28.1 | BSD-3-Clause | https://github.com/encode/httpx | License field | +| httpx-ws | 0.9.0 | MIT | https://github.com/frankie567/httpx-ws | classifier | | httpx2 | 2.13.1 | BSD-3-Clause | https://github.com/pydantic/httpx2 | License-Expression | | huggingface-hub | 1.18.0 | Apache-2.0 | https://github.com/huggingface/huggingface_hub | License field | | humanize | 4.16.0 | MIT | https://github.com/python-humanize/humanize | License-Expression | @@ -182,14 +194,16 @@ names no author is in the evidence column below. | multidict | 6.9.1 | Apache-2.0 | https://github.com/aio-libs/multidict | License field | | numpy | 2.5.3 | BSD-3-Clause AND 0BSD AND MIT AND Zlib AND CC0-1.0 | https://github.com/numpy/numpy | License-Expression | | oauthlib | 4.0.0 | BSD-3-Clause | https://github.com/oauthlib/oauthlib | License field | +| obstore | 0.11.1 | MIT | https://developmentseed.org/obstore | classifier; no licence file, attributed to Development Seed | | odfpy | 1.4.1 | LGPL-2.1-or-later | https://github.com/eea/odfpy | override: odfpy-1.4.1 ships no licence file and its License field is empty; its trove classifiers name Apache, GPL and LGPL, but every library source under odf/ (e.g. odf/namespaces.py, odf/office.py) carries the same header - 'This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License ... version 2.1 ... or (at your option) any later version', Copyright (C) 2006-2013 Søren Roug, European Environment Agency. The imported library is therefore taken under LGPL-2.1-or-later.; review accepted; no licence file, attributed to Soren Roug | | olefile | 0.47 | BSD-2-Clause | https://www.decalage.info/python/olefileio | licence file text | -| openai | 3.14.0 | Apache-2.0 | https://github.com/openai/openai-python | License-Expression | +| openai | 3.24.0 | Apache-2.0 | https://github.com/openai/openai-python | License-Expression | | openpyxl | 3.1.5 | MIT | https://foss.heptapod.net/openpyxl/openpyxl | License field | | opentelemetry-api | 1.44.0 | Apache-2.0 | https://github.com/open-telemetry/opentelemetry-python | License-Expression | | opentelemetry-exporter-otlp-proto-common | 1.44.0 | Apache-2.0 | https://github.com/open-telemetry/opentelemetry-python | License-Expression | | opentelemetry-exporter-otlp-proto-http | 1.44.0 | Apache-2.0 | https://github.com/open-telemetry/opentelemetry-python | License-Expression | | opentelemetry-instrumentation | 0.65b0 | Apache-2.0 | https://github.com/open-telemetry/opentelemetry-python-contrib | License-Expression | +| opentelemetry-instrumentation-aiohttp-client | 0.65b0 | Apache-2.0 | https://github.com/open-telemetry/opentelemetry-python-contrib | License-Expression | | opentelemetry-instrumentation-asgi | 0.65b0 | Apache-2.0 | https://github.com/open-telemetry/opentelemetry-python-contrib | License-Expression | | opentelemetry-instrumentation-asyncpg | 0.65b0 | Apache-2.0 | https://github.com/open-telemetry/opentelemetry-python-contrib | License-Expression | | opentelemetry-instrumentation-fastapi | 0.65b0 | Apache-2.0 | https://github.com/open-telemetry/opentelemetry-python-contrib | License-Expression | @@ -221,13 +235,13 @@ names no author is in the evidence column below. | pyasn1-modules | 0.4.2 | BSD-2-Clause | https://github.com/pyasn1/pyasn1-modules | licence file text | | pycparser | 3.0 | BSD-3-Clause | https://github.com/eliben/pycparser | License-Expression | | pydantic | 2.13.5 | MIT | https://github.com/pydantic/pydantic | License-Expression | -| pydantic-ai-backend | 0.2.29 | MIT | https://github.com/vstorm-co/pydantic-ai-backend | License-Expression | -| pydantic-ai-harness | 0.35.0 | MIT | https://github.com/pydantic/pydantic-ai-harness | License-Expression | +| pydantic-ai-backend | 0.2.32 | MIT | https://github.com/vstorm-co/pydantic-ai-backend | License-Expression | +| pydantic-ai-harness | 0.54.0 | MIT | https://github.com/pydantic/pydantic-ai | License-Expression | | pydantic-ai-skills | 2.0.0 | MIT | https://github.com/dougtrajano/pydantic-ai-skills | License-Expression | -| pydantic-ai-slim | 2.45.0 | MIT | https://github.com/pydantic/pydantic-ai/tree/main/pydantic_ai_slim | License-Expression | +| pydantic-ai-slim | 2.54.0 | MIT | https://github.com/pydantic/pydantic-ai/tree/main/pydantic_ai_slim | License-Expression | | pydantic-core | 2.46.5 | MIT | https://github.com/pydantic/pydantic/tree/main/pydantic-core | License-Expression | | pydantic-extra-types | 2.11.1 | MIT | https://github.com/pydantic/pydantic-extra-types | License-Expression | -| pydantic-graph | 2.45.0 | MIT | https://github.com/pydantic/pydantic-ai | License-Expression | +| pydantic-graph | 2.54.0 | MIT | https://github.com/pydantic/pydantic-ai | License-Expression | | pydantic-monty | 1.0.0 | MIT | https://github.com/pydantic/monty | License-Expression; no licence file, attributed to Pydantic Services Inc. | | pydantic-monty-client | 1.0.0 | MIT | https://github.com/pydantic/monty | License field; no licence file, attributed to Pydantic Services Inc. | | pydantic-monty-runtime | 1.0.0 | MIT | https://github.com/pydantic/monty | License field; no licence file, attributed to Pydantic Services Inc. | @@ -240,10 +254,12 @@ names no author is in the evidence column below. | python-dateutil | 2.9.0.post0 | Apache-2.0 | https://github.com/dateutil/dateutil | licence file text | | python-docx | 1.2.0 | MIT | https://github.com/python-openxml/python-docx | License field | | python-dotenv | 1.2.3 | BSD-3-Clause | https://github.com/theskumar/python-dotenv | License field | +| python-engineio | 4.14.0 | MIT | https://code.miguelgrinberg.com/miguelgrinberg/python-engineio | License field | | python-json-logger | 4.2.0 | BSD-2-Clause | https://nhairs.github.io/python-json-logger | License-Expression | | python-multipart | 0.0.32 | Apache-2.0 | https://github.com/Kludex/python-multipart | License-Expression | | python-pptx | 1.0.2 | MIT | https://github.com/scanny/python-pptx | License field | | python-slugify | 8.0.4 | MIT | https://github.com/un33k/python-slugify | License field | +| python-socketio | 5.17.0 | MIT | https://code.miguelgrinberg.com/miguelgrinberg/python-socketio | License field | | pytz | 2026.3.post1 | MIT | https://github.com/stub42/pytz.git | License field | | pyyaml | 6.0.3 | MIT | https://github.com/yaml/pyyaml | License field | | qdrant-client | 1.19.0 | Apache-2.0 | https://github.com/qdrant/qdrant-client | License field | @@ -264,6 +280,7 @@ names no author is in the evidence column below. | secretstorage | 3.5.0 | BSD-3-Clause | https://github.com/mitya57/secretstorage | License-Expression | | semver | 3.1.0 | BSD-3-Clause | https://python-semver.rtfd.io | licence file text | | shellingham | 1.5.4 | ISC | https://github.com/sarugaku/shellingham | License field | +| simple-websocket | 1.1.0 | MIT | https://github.com/miguelgrinberg/simple-websocket | classifier | | six | 1.17.0 | MIT | https://github.com/benjaminp/six | License field | | slack-sdk | 3.44.1 | MIT | https://github.com/slackapi/python-slack-sdk | License field | | sniffio | 1.3.1 | Apache-2.0 | https://github.com/python-trio/sniffio | licence file text | @@ -271,7 +288,7 @@ names no author is in the evidence column below. | sqlalchemy | 2.1.0 | MIT | https://github.com/sqlalchemy/sqlalchemy | License-Expression | | sse-starlette | 3.4.11 | BSD-3-Clause | https://github.com/sysid/sse-starlette | License-Expression | | starlette | 1.6.0 | BSD-3-Clause | https://github.com/Kludex/starlette | License-Expression | -| subagents-pydantic-ai | 0.2.22 | MIT | https://github.com/vstorm-co/subagents-pydantic-ai | License field | +| subagents-pydantic-ai | 0.2.25 | MIT | https://github.com/vstorm-co/subagents-pydantic-ai | License field | | tabulate | 0.10.0 | MIT | https://github.com/astanin/python-tabulate | License-Expression | | tavily-python | 0.8.4 | MIT | https://github.com/tavily-ai/tavily-python | classifier | | tenacity | 9.1.4 | Apache-2.0 | https://github.com/jd/tenacity | License field | @@ -294,8 +311,10 @@ names no author is in the evidence column below. | uvloop | 0.22.1 | MIT | https://github.com/MagicStack/uvloop | License field | | watchfiles | 1.2.0 | MIT | https://github.com/samuelcolvin/watchfiles | License field | | wcmatch | 11.0.1 | MIT | https://github.com/facelessuser/wcmatch | License-Expression | +| websocket-client | 1.9.2 | Apache-2.0 | https://github.com/websocket-client/websocket-client/ | License field | | websockets | 16.1.1 | BSD-3-Clause | https://github.com/python-websockets/websockets | License-Expression | | wrapt | 2.4.1 | BSD-2-Clause | https://github.com/GrahamDumpleton/wrapt | License-Expression | +| wsproto | 1.3.2 | MIT | https://github.com/python-hyper/wsproto/ | License-Expression | | xai-sdk | 1.19.0 | Apache-2.0 | https://github.com/xai-org/xai-sdk-python | License-Expression | | xlrd | 2.0.2 | BSD-2-Clause | http://www.python-excel.org/ | licence file text | | xlsxwriter | 3.2.9 | BSD-2-Clause | https://github.com/jmcnamara/XlsxWriter | License field | diff --git a/backend/alembic/versions/0105_workspace_directories.py b/backend/alembic/versions/0105_workspace_directories.py new file mode 100644 index 000000000..4606459ab --- /dev/null +++ b/backend/alembic/versions/0105_workspace_directories.py @@ -0,0 +1,37 @@ +"""Keep a stored workspace's empty directories beside its files. + +pydantic-ai-backend 0.2.30 moved the agent's files onto Pydantic AI workspaces, +and the `StateBackend` a `state` workspace is stored as became a filesystem with +directories of its own: one `make_dir` created, or one the agent emptied, still +exists. `files` cannot say so - it holds files by path - so the document is +stored as both, the way the library persists it. Existing rows keep null, which +loads as no directories created: every directory holding a file exists anyway, +so their trees are unchanged. + +Revision ID: 0105_workspace_directories +Revises: 0104_model_profile_api +Create Date: 2026-10-05 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision: str = "0105_workspace_directories" +down_revision: str | None = "0104_model_profile_api" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "agent_workspaces", + sa.Column("directories", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("agent_workspaces", "directories") diff --git a/backend/app/agents/capabilities/artifacts/__init__.py b/backend/app/agents/capabilities/artifacts/__init__.py index 3aba82561..2b69e644d 100644 --- a/backend/app/agents/capabilities/artifacts/__init__.py +++ b/backend/app/agents/capabilities/artifacts/__init__.py @@ -6,7 +6,6 @@ register, ) from app.agents.capabilities.artifacts._capability import Artifacts -from app.agents.capabilities.sandbox import WORKSPACE_BACKEND_RESOURCE __all__ = ["ARTIFACTS_CAPABILITY_ID", "Artifacts"] @@ -42,4 +41,4 @@ ) def _build(ctx: CapabilityBuildContext) -> Artifacts: """Never `None`: an agent without a workspace still publishes content it passes inline.""" - return Artifacts(workspace_backend=ctx.resources.get(WORKSPACE_BACKEND_RESOURCE)) + return Artifacts() diff --git a/backend/app/agents/capabilities/artifacts/_capability.py b/backend/app/agents/capabilities/artifacts/_capability.py index a6ae53e7c..b18892e85 100644 --- a/backend/app/agents/capabilities/artifacts/_capability.py +++ b/backend/app/agents/capabilities/artifacts/_capability.py @@ -20,16 +20,11 @@ class Artifacts(AbstractCapability[AgentDepsT]): identity, and why the page is served in a sandbox with no network. """ - # The run's workspace backend when one is open, so a page the agent built on - # disk can be published as the file it is. `None` for an agent without the - # sandbox capability, which publishes content it passes inline instead. - workspace_backend: Any | None = field(default=None, repr=False, compare=False) - _toolset: AbstractToolset[Any] | None = field( default=None, init=False, repr=False, compare=False ) def get_toolset(self) -> AbstractToolset[Any]: if self._toolset is None: - self._toolset = build_artifacts_toolset(workspace_backend=self.workspace_backend) + self._toolset = build_artifacts_toolset() return self._toolset diff --git a/backend/app/agents/capabilities/artifacts/_toolset.py b/backend/app/agents/capabilities/artifacts/_toolset.py index 4e6a70eb6..3785d6069 100644 --- a/backend/app/agents/capabilities/artifacts/_toolset.py +++ b/backend/app/agents/capabilities/artifacts/_toolset.py @@ -18,13 +18,13 @@ from __future__ import annotations from pathlib import PurePosixPath -from typing import Any, Literal +from typing import Literal from uuid import UUID from pydantic import BaseModel, Field, ValidationError from pydantic_ai.tools import RunContext from pydantic_ai.toolsets import FunctionToolset -from pydantic_ai_backends import ensure_async +from pydantic_ai.workspaces import WorkspaceError from app.agents.audience import RunAudience from app.agents.capabilities._failures import steer @@ -147,7 +147,7 @@ def apply_edits(source: str, edits: list[ArtifactEdit]) -> str: return text -def build_artifacts_toolset(*, workspace_backend: Any | None) -> FunctionToolset[AgentDeps]: +def build_artifacts_toolset() -> FunctionToolset[AgentDeps]: """`publish_artifact` and `read_artifact`, reading from the run's workspace when it has one.""" async def publish_artifact( @@ -250,7 +250,7 @@ async def publish_artifact( data = content.encode("utf-8") elif path is None: return steer(ctx, _ONE_SOURCE) - elif workspace_backend is None: + elif not ctx.workspace.attached: return steer( ctx, "This agent has no workspace to read a file from. Pass the page as " @@ -258,9 +258,16 @@ async def publish_artifact( ) else: try: - data = await ensure_async(workspace_backend).read_bytes(path) + data = await ctx.workspace.read_bytes(path) except PermissionError as exc: return f"Reading {path!r} was refused: {exc}" + except (FileNotFoundError, IsADirectoryError, NotADirectoryError): + data = b"" + except WorkspaceError as exc: + # A container's shell could not produce the file - the + # session went away, its output came back damaged. The + # model can retry or publish inline; the run goes on. + return f"Reading {path!r} failed: {exc}" if not data: return steer( ctx, diff --git a/backend/app/agents/capabilities/compaction/README.md b/backend/app/agents/capabilities/compaction/README.md index cf48c7132..439549011 100644 --- a/backend/app/agents/capabilities/compaction/README.md +++ b/backend/app/agents/capabilities/compaction/README.md @@ -167,9 +167,11 @@ It wraps the zero-LLM strategies too. An allowlist of "these can spend" is a lis somebody has to remember to add to, and the entry they forget is a model call nobody is billed for — the same omission the wrapper exists to close. -What it cannot do is *stop* the spend. `BudgetGuard` refuses in -`wrap_model_request`, which runs after this hook, so a compaction that crosses a -cap is recorded here and refused on the request after it. +What it cannot do is stop the summary itself once it has started. It checks the +budget again after a summary was paid for: since pydantic-ai 2.54 `BudgetGuard`'s +`wrap_model_request` encloses this hook rather than following it (pydantic-ai#7053), +so its own check ran before the summary, and a summary that crossed a cap would +otherwise let the request after it go out and be billed. ## The gauge diff --git a/backend/app/agents/capabilities/compaction/_capability.py b/backend/app/agents/capabilities/compaction/_capability.py index 5ca785a2b..b19b7c0ce 100644 --- a/backend/app/agents/capabilities/compaction/_capability.py +++ b/backend/app/agents/capabilities/compaction/_capability.py @@ -56,6 +56,7 @@ from app.agents.capabilities._ambient import run_model_settings from app.agents.capabilities.budget import ( + assert_ambient_budget, can_afford_ambient_call, record_ambient_usage, usage_counts, @@ -480,10 +481,11 @@ class MeteredCompaction(WrapperCapability[AgentDepsT]): This books the spend; what *stops* it is :meth:`NotifyingSummarizingCompaction.compact`, which asks :func:`~app.agents.capabilities.budget.can_afford_ambient_call` before the - summary and skips it at a cap (agenticos#1808). `BudgetGuard` still refuses the - next real request in `wrap_model_request`, which runs after this hook, so a - summary that crosses a cap mid-run is recorded here and refused on the request - after it. + summary and skips it at a cap (agenticos#1808). A summary that was paid for is + followed by a second check here: since pydantic-ai 2.54 every `wrap_*` hook + encloses every `before_*` hook (pydantic-ai#7053), so `BudgetGuard` asked + before the summary was bought, and the request this hook hands on would + otherwise go out past a cap the summary crossed. """ gauge: ContextGauge | None = None @@ -505,11 +507,14 @@ async def before_model_request( return request_context before = usage_counts(ctx.usage) try: - return await self.wrapped.before_model_request(ctx, request_context) + compacted = await self.wrapped.before_model_request(ctx, request_context) finally: spent = usage_delta(before, ctx.usage) if spent is not None: record_ambient_usage(_model_name(request_context), spent) + if spent is not None: + await assert_ambient_budget() + return compacted async def _has_no_room(self, ctx: RunContext[AgentDepsT]) -> bool: """Whether this window is too small for a summary to ever get under it. diff --git a/backend/app/agents/capabilities/image_generation/README.md b/backend/app/agents/capabilities/image_generation/README.md index 39fc145cc..b04cc1b01 100644 --- a/backend/app/agents/capabilities/image_generation/README.md +++ b/backend/app/agents/capabilities/image_generation/README.md @@ -46,9 +46,9 @@ Two places, and they answer different needs: works for every agent. - **When a workspace is open** (an agent with the `sandbox` capability), the same bytes are also written under `/output`, so a later `execute` step can build with - the image it just made - assemble a PDF, a slide, a page. The workspace backend - arrives as a build resource (`WORKSPACE_BACKEND_RESOURCE`); an agent without one - still generates and shows images, it just has nowhere to build. + the image it just made - assemble a PDF, a slide, a page. The tool writes to + the run's workspace (`ctx.workspace`); an agent without one still generates + and shows images, it just has nowhere to build. Storage is **organization-scoped, not per-user** - wider than a chat upload, because there is no row recording who produced an image, so the tenant is the diff --git a/backend/app/agents/capabilities/image_generation/__init__.py b/backend/app/agents/capabilities/image_generation/__init__.py index 8a2a5bc4c..5697e3d90 100644 --- a/backend/app/agents/capabilities/image_generation/__init__.py +++ b/backend/app/agents/capabilities/image_generation/__init__.py @@ -11,7 +11,6 @@ register, ) from app.agents.capabilities.image_generation._capability import ImageGeneration -from app.agents.capabilities.sandbox import WORKSPACE_BACKEND_RESOURCE from app.core.secret_kinds import ApiKeySecret, SecretKind, SecretRequirement from app.services.image_models import ( default_choice, @@ -165,5 +164,4 @@ def _build(ctx: CapabilityBuildContext) -> ImageGeneration: model_id=resolved_model_id(config.provider, config.model), tool_settings=config.to_tool_kwargs(), api_key=api_key, - workspace_backend=ctx.resources.get(WORKSPACE_BACKEND_RESOURCE), ) diff --git a/backend/app/agents/capabilities/image_generation/_capability.py b/backend/app/agents/capabilities/image_generation/_capability.py index 34f7ced1f..05aaac938 100644 --- a/backend/app/agents/capabilities/image_generation/_capability.py +++ b/backend/app/agents/capabilities/image_generation/_capability.py @@ -31,11 +31,6 @@ class ImageGeneration(AbstractCapability[AgentDepsT]): # either is a key to rotate. `None` only on the preview and test paths that # build without a secret; a published agent always has one. api_key: str | None = field(default=None, repr=False) - # The run's workspace backend when one is open, so a generated image can be - # written into it. `None` for an agent without the sandbox capability, which - # generates images all the same - it simply has nowhere to build with them. - workspace_backend: Any | None = field(default=None, repr=False, compare=False) - _toolset: AbstractToolset[Any] | None = field( default=None, init=False, repr=False, compare=False ) @@ -46,6 +41,5 @@ def get_toolset(self) -> AbstractToolset[Any]: model_id=self.model_id, api_key=self.api_key, tool_settings=self.tool_settings, - workspace_backend=self.workspace_backend, ) return self._toolset diff --git a/backend/app/agents/capabilities/image_generation/_toolset.py b/backend/app/agents/capabilities/image_generation/_toolset.py index 9b40354ff..b26d4822f 100644 --- a/backend/app/agents/capabilities/image_generation/_toolset.py +++ b/backend/app/agents/capabilities/image_generation/_toolset.py @@ -38,7 +38,7 @@ from pydantic_ai.providers import infer_provider_class from pydantic_ai.tools import RunContext from pydantic_ai.toolsets import FunctionToolset -from pydantic_ai_backends import ensure_async +from pydantic_ai.workspaces import WorkspaceError from app.agents.capabilities._failures import steer from app.agents.capabilities.budget import record_ambient_usage @@ -119,13 +119,11 @@ def build_image_toolset( model_id: str, api_key: str | None, tool_settings: dict[str, Any], - workspace_backend: Any | None, ) -> FunctionToolset[Any]: """A `generate_image` tool bound to one image model and its key. - Everything the model must not choose is closed over: the model, the key, the - image settings and the workspace it may write into. The model chooses only the - prompt. + Everything the model must not choose is closed over: the model, the key and the + image settings. The model chooses only the prompt. """ async def generate_image(ctx: RunContext[AgentDeps], prompt: str) -> str: @@ -195,11 +193,21 @@ async def generate_image(ctx: RunContext[AgentDeps], prompt: str) -> str: ) url = generated_image_url(filename) + # Into the run's workspace too, when it has one, for later steps to build + # with. An agent without the sandbox capability generates images all the + # same - it simply has nowhere to build with them. workspace_path: str | None = None - if workspace_backend is not None: + if ctx.workspace.attached: leaf = filename or f"image.{image_format}" - workspace_path = f"{WORKSPACE_OUTPUT_DIR}/{leaf}" - await ensure_async(workspace_backend).write(workspace_path, image.data) + try: + await ctx.workspace.write_bytes(f"{WORKSPACE_OUTPUT_DIR}/{leaf}", image.data) + workspace_path = f"{WORKSPACE_OUTPUT_DIR}/{leaf}" + except (OSError, WorkspaceError): + # A full workspace - a `state` document past its ceiling raises + # `OSError`, a container's shell `WorkspaceError` - the image is + # still stored and linked above, and naming a path that holds + # nothing would send the model to it. + logger.info("generated_image_not_written", extra={"leaf": leaf}) return GeneratedImage( filename=filename, diff --git a/backend/app/agents/capabilities/sandbox/__init__.py b/backend/app/agents/capabilities/sandbox/__init__.py index b90dfe118..b4327e727 100644 --- a/backend/app/agents/capabilities/sandbox/__init__.py +++ b/backend/app/agents/capabilities/sandbox/__init__.py @@ -19,11 +19,11 @@ from __future__ import annotations -from typing import Literal +from typing import Any, Literal from uuid import UUID from pydantic import BaseModel, Field -from pydantic_ai_backends import ConsoleCapability +from pydantic_ai.capabilities import CombinedCapability from app.agents.capabilities._registry import CapabilityBuildContext, register from app.agents.capabilities.sandbox._capability import WORKSPACE_TOOLS, build_workspace @@ -36,7 +36,7 @@ ) __all__ = [ - "WORKSPACE_BACKEND_RESOURCE", + "WORKSPACE_RESOURCE", "BackendKind", "SandboxConfig", "SessionScope", @@ -45,14 +45,18 @@ "scope_key", ] -WORKSPACE_BACKEND_RESOURCE = "workspace_backend" -"""Where the runner leaves the backend it opened for this run. +WORKSPACE_RESOURCE = "workspace" +"""Where the runner leaves the workspace it opened for this run. Resolved outside the capability because opening one reads and writes the database - loading a stored `state` document, recording which session id belongs to which conversation - and a capability must never reach the database itself. -Absent for a preview or a unit test, where an in-memory workspace that lives and -dies with the run is the honest answer rather than an error. +Tools work in `ctx.workspace`, which the runner sets by passing this same +workspace to the run. The resource is for what is built before the run starts +and so has no `ctx` to ask - the spill store of `tool_output_limits` - and for +handing a delegate its parent's workspace. Absent for a preview or a unit test, +where an in-memory workspace that lives and dies with the run is the honest +answer rather than an error. """ @@ -144,15 +148,12 @@ class SandboxConfig(BaseModel): # concept with the Daytona account an organization bills its cloud sandboxes # to. A connection carries both. ) -def _build(ctx: CapabilityBuildContext) -> ConsoleCapability: - """Wrap the backend the runner opened in the library's console toolset. +def _build(ctx: CapabilityBuildContext) -> CombinedCapability[Any]: + """The library's console tools, working in the run's workspace. - The backend arrives through `resources` rather than being built here: - opening one reads the database, and this runs inside `build_agent`, which - has no session and must not acquire one. + The workspace is not built here: opening one reads the database, and this + runs inside `build_agent`, which has no session and must not acquire one. + The runner opens it and passes it to the run. """ config = ctx.config if isinstance(ctx.config, SandboxConfig) else SandboxConfig() - return build_workspace( - backend=ctx.resources.get(WORKSPACE_BACKEND_RESOURCE), - include_execute=config.include_execute, - ) + return build_workspace(include_execute=config.include_execute) diff --git a/backend/app/agents/capabilities/sandbox/_capability.py b/backend/app/agents/capabilities/sandbox/_capability.py index 4e7d120e6..1a481ec10 100644 --- a/backend/app/agents/capabilities/sandbox/_capability.py +++ b/backend/app/agents/capabilities/sandbox/_capability.py @@ -14,12 +14,10 @@ from __future__ import annotations -from pydantic_ai_backends import ( - AsyncBackendProtocol, - BackendProtocol, - ConsoleCapability, - StateBackend, -) +from typing import Any + +from pydantic_ai.capabilities import CombinedCapability +from pydantic_ai_backends import ConsoleCapability, StateWorkspace from pydantic_ai_backends.toolsets.descriptions import TOOL_TEXT from app.agents.capabilities._registry import CapabilityToolInfo @@ -68,28 +66,27 @@ """ -def build_workspace( - *, backend: BackendProtocol | AsyncBackendProtocol | None, include_execute: bool -) -> ConsoleCapability: - """The console capability this agent runs with. +def build_workspace(*, include_execute: bool) -> CombinedCapability[Any]: + """The console tools this agent runs with, and where they work without a runner. + + The tools work in the run's workspace, `ctx.workspace`. The runner opens the + agent's workspace - a stored document, a container, a cloud sandbox - and + passes it to the run, which overrides anything here. Where nobody opened + one - a preview, a test - the run gets an in-memory document that lives + exactly as long as it does: the honest answer there, rather than an error + about infrastructure the author did not ask for. Args: - backend: The workspace the runner opened, or `None` where there is - nowhere durable to put files - a preview, a test. An in-memory - workspace that lives exactly as long as the run is the honest - answer there, rather than an error about infrastructure the author - did not ask for. include_execute: Whether the shell is offered at all. Off removes it rather than gating it, which is a different decision from "ask first" and belongs to whoever configured the agent. """ + return CombinedCapability([StateWorkspace(), _console(include_execute=include_execute)]) + + +def _console(*, include_execute: bool) -> ConsoleCapability: return ConsoleCapability( - backend=backend if backend is not None else StateBackend(), include_execute=include_execute, - # Four more tools, none of them declared above, and a process left - # running in a sandbox nobody watches finish. Not offered, so not a - # configuration somebody can arrive at by accident. - include_background=False, # So `read_file` on an image returns something a multimodal model can # see. Without it an agent cannot look at the chart it just rendered, # which is most of the reason to let it render one. diff --git a/backend/app/agents/capabilities/sandbox/_capped.py b/backend/app/agents/capabilities/sandbox/_capped.py index 25d03c760..3a0c1414a 100644 --- a/backend/app/agents/capabilities/sandbox/_capped.py +++ b/backend/app/agents/capabilities/sandbox/_capped.py @@ -6,140 +6,86 @@ one turns a chat into a row nobody can load. The cap is applied on the way in rather than at the flush, and this is the whole -reason the wrapper exists. Refusing at flush time would accept every write, +reason the subclass exists. Refusing at flush time would accept every write, report success to the model, and then silently drop the run's work in a `finally` block - the agent would spend its remaining turns reasoning about a file that was never kept. Refusing at the call site gives the model an error it -can read and act on, which is what the backend protocol asks of a failure. +can read and act on: the console tools report an `OSError` as the call's error. """ from __future__ import annotations +import errno import json -from typing import cast +from collections.abc import Iterable -from pydantic_ai_backends import ( - EditResult, - FileData, - FileInfo, - GrepMatch, - StateBackend, - WriteResult, -) +from pydantic_ai_backends import FileData, StateBackend -def document_size(files: dict[str, FileData]) -> int: +def document_size(files: dict[str, FileData], directories: Iterable[str] = ()) -> int: """How many bytes storing this document costs. Measured as the serialised form because that is what the cap protects - the - column - rather than the length of the content, which ignores base64 - expansion and the per-file bookkeeping. + columns - rather than the length of the content, which ignores base64 + expansion and the per-file bookkeeping. The directories are stored beside the + files and count too: every parent of a deep path is one, so a path nested a + few thousand levels would otherwise grow the row past the ceiling uncounted. """ - return len(json.dumps(files, ensure_ascii=False).encode("utf-8")) + stored = json.dumps(files, ensure_ascii=False) + json.dumps( + sorted(directories), ensure_ascii=False + ) + return len(stored.encode("utf-8")) -class CappedStateBackend: - """A `StateBackend` that refuses to exceed a byte ceiling. +class CappedStateBackend(StateBackend): + """A `StateBackend` document that refuses to exceed a byte ceiling. - Delegates everything that cannot grow the document. `write` and `edit` are - performed, measured, and rolled back when they take it over the line, so the - document is never left in a state the store would reject. + Only a write grows the document by more than a path, so only a write is + measured: it is performed, measured, and rolled back when it takes the + document over the line, so the document is never left in a state the store + would reject. """ - def __init__(self, backend: StateBackend, *, max_bytes: int) -> None: - self._backend = backend + def __init__( + self, + files: dict[str, FileData] | None = None, + directories: Iterable[str] | None = None, + *, + max_bytes: int, + ) -> None: + super().__init__(files, directories) self._max_bytes = max_bytes - @property - def files(self) -> dict[str, FileData]: - return self._backend.files - - def _snapshot(self) -> dict[str, FileData]: - """A restorable copy of the document. - - One level deep is enough and worth the precision, and it holds because of - what the library does rather than by luck: `StateBackend.write` builds a - fresh `FileData` and rebinds `files[path]`, and `edit` assigns - `stored["content"] = ...` - both replace the list rather than mutating it, - so copying each entry's mapping preserves the previous value while the - line lists stay shared. `files` is the live dict too, so `_restore` - reaches the backend rather than a copy of it. Deep-copying a - four-megabyte document on every write would cost more than the write. - """ - return {path: cast(FileData, dict(entry)) for path, entry in self._backend.files.items()} - - def _restore(self, snapshot: dict[str, FileData]) -> None: - self._backend.files.clear() - self._backend.files.update(snapshot) - - def _too_large(self) -> str | None: - """Whether the document is now over the line, and what to tell the model. + def write_bytes(self, path: str, data: bytes) -> None: + """Store a file, unless that takes the document past the ceiling. - The measure is the absolute size rather than the change, so a document - that is *already* over the ceiling can only be brought back under in one - move - a partial cleanup is refused and rolled back with it. Left that way - deliberately: getting there needs `SANDBOX_STATE_MAX_BYTES` lowered - beneath a document that was stored under the old value, which is an - operator's change and not something an agent can do to itself. A delta - check would be more code for a case that only a downgrade reaches. + Raises: + OSError: `ENOSPC`, with what to tell the model, when it would. """ - size = document_size(self._backend.files) + # A shallow copy restores it: a write binds a new `FileData` to the path + # rather than changing the stored one, and records new parents in + # `directories` without touching the others. + files_before = dict(self.files) + directories_before = set(self.directories) + super().write_bytes(path, data) + size = document_size(self.files, self.directories) if size <= self._max_bytes: - return None - # "Shorten or overwrite", not "delete": `StateBackend` exposes no delete - # and `WORKSPACE_TOOLS` declares none, so there is no tool for it. Naming - # one would send the model looking for something that is not there. - return ( + return + self.files.clear() + self.files.update(files_before) + self.directories.clear() + self.directories.update(directories_before) + # The measure is the absolute size rather than the change, so a document + # that is *already* over the ceiling - `SANDBOX_STATE_MAX_BYTES` lowered + # beneath one stored under the old value - can only shrink. "Shorten or + # overwrite", not "delete": the console offers no delete, and naming one + # would send the model looking for something that is not there. + raise OSError( + errno.ENOSPC, f"The workspace is full: this would take it to {size} bytes, over the " f"{self._max_bytes}-byte limit. Shorten or overwrite something first, or " - "keep large intermediate results out of the workspace." + "keep large intermediate results out of the workspace.", ) - def write(self, path: str, content: str | bytes) -> WriteResult: - snapshot = self._snapshot() - result = self._backend.write(path, content) - if result.error is not None: - return result - if (reason := self._too_large()) is not None: - self._restore(snapshot) - return WriteResult(error=reason) - return result - - def edit( - self, path: str, old_string: str, new_string: str, replace_all: bool = False - ) -> EditResult: - snapshot = self._snapshot() - result = self._backend.edit(path, old_string, new_string, replace_all) - if result.error is not None: - return result - if (reason := self._too_large()) is not None: - self._restore(snapshot) - return EditResult(error=reason) - return result - - def exists(self, path: str) -> bool: - return self._backend.exists(path) - - def ls_info(self, path: str) -> list[FileInfo]: - return self._backend.ls_info(path) - - def read_bytes(self, path: str) -> bytes: - return self._backend.read_bytes(path) - - def read(self, path: str, offset: int = 0, limit: int = 2000) -> str: - return self._backend.read(path, offset, limit) - - def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]: - return self._backend.glob_info(pattern, path) - - def grep_raw( - self, - pattern: str, - path: str | None = None, - glob: str | None = None, - ignore_hidden: bool = True, - ) -> list[GrepMatch] | str: - return self._backend.grep_raw(pattern, path, glob, ignore_hidden) - def __repr__(self) -> str: - return f"" + return f"" diff --git a/backend/app/agents/capabilities/sandbox/_recording.py b/backend/app/agents/capabilities/sandbox/_recording.py index d588b6269..740af5bf2 100644 --- a/backend/app/agents/capabilities/sandbox/_recording.py +++ b/backend/app/agents/capabilities/sandbox/_recording.py @@ -1,21 +1,29 @@ -"""A backend that records what was done to it, so the log outlives the service. +"""A workspace that records what was done to it, so the log outlives the service. The sandbox service keeps its own activity log and it is a 200-entry ring buffer in that process's memory: what it dropped cannot be asked for, and restarting the service loses every log on the host (agenticos#1061). Every workspace call already passes through this application, so the record is ours to make. -**A wrapper rather than a call in each tool.** The capability's tools reach the -backend through this one object, so wrapping it records every operation exactly -once and cannot be forgotten by whoever adds the ninth tool - the same reason -`CappedStateBackend` is a wrapper and `ensure_async` is a wrapper. +**A wrapper around the workspace rather than a call in each tool.** Every tool +reaches the sandbox through the run's workspace - the console's seven, a +harness capability's, a skill's script - so wrapping it records every operation +exactly once and cannot be forgotten by whoever adds the next tool. + +**Recorded at the workspace's level, named as before.** An operation is what +reached the sandbox: `read`, `write`, `ls_info`, `execute`, and now `mkdir` and +`remove`. A tool that is several operations shows as each of them - an edit is a +`read` and a `write`, a `glob` or a `grep` the `execute` of the `find` or `grep` +it ran - which is what happened in the sandbox, and the filter keeps working on +the names it already had. Checks (`exists`, `stat`) are questions rather than +operations, and a log full of them would bury the writes somebody came to read. **What is written is a path, never a payload.** `write` records the path and how -many bytes; `exec` records the command and never its output; `read` records the -path and never the contents. These rows are readable by everyone who can see the -sandbox, and a log that carried contents would be a way to read an agent's work -rather than an audit of it - which is the line the service itself draws and the -sentence the dialog already shows. +many bytes; `execute` records the command and never its output; `read` records +the path and never the contents. These rows are readable by everyone who can see +the sandbox, and a log that carried contents would be a way to read an agent's +work rather than an audit of it - which is the line the service itself draws and +the sentence the dialog already shows. **And the rows land when the run's transaction commits**, because they are written into the run's own session rather than a connection per tool call. So a turn's @@ -26,35 +34,33 @@ from __future__ import annotations -import inspect import logging +import shlex import time -from typing import TYPE_CHECKING, Any +from collections.abc import Awaitable, Callable, Mapping, Sequence +from typing import TYPE_CHECKING, TypeVar from uuid import UUID +from pydantic_ai.workspaces import ( + CommandResult, + FileEntry, + Workspace, + WorkspaceCommand, + WorkspaceUnavailableError, + WrapperWorkspace, +) + if TYPE_CHECKING: # pragma: no cover - imported for typing only from sqlalchemy.ext.asyncio import AsyncSession logger = logging.getLogger(__name__) -# The methods worth recording, and what each one's `target` is. A method absent -# here is delegated untouched: `exists` and `is_alive` are questions rather than -# operations, and a log full of them would bury the writes somebody came to read. -_TARGET_ARG = { - "write": 0, - "edit": 0, - "read": 0, - "read_bytes": 0, - "ls_info": 0, - "glob_info": 0, - "grep_raw": 0, - "execute": 0, -} - _MAX_TARGET = 512 +_T = TypeVar("_T") + -def _target(value: object) -> str: +def _target(value: str) -> str: """One operation's subject, as a bounded string. A command is whatever the model wrote and a path is whatever it chose, so both @@ -62,22 +68,19 @@ def _target(value: object) -> str: refused: a log that dropped an operation because its command was long would be missing exactly the entry somebody is looking for. """ - text = value if isinstance(value, str) else repr(value) - return text[:_MAX_TARGET] + return value[:_MAX_TARGET] -class RecordingBackend: - """Delegates every call, and records the ones that change or read a workspace. +def _command_text(command: WorkspaceCommand) -> str: + return command if isinstance(command, str) else shlex.join(command) - `__getattr__` rather than a method per operation, deliberately: the backend - protocol has grown twice and a hand-written façade is a façade that silently - stops recording whatever was added. What is *not* recorded is a short list - above, which is the decision worth reading rather than eight identical methods. - """ + +class RecordingWorkspace(WrapperWorkspace): + """Delegates every call, and records the ones that change or read a workspace.""" def __init__( self, - backend: Any, + wrapped: Workspace, *, db: AsyncSession, organization_id: UUID, @@ -85,7 +88,7 @@ def __init__( agent_id: UUID | None, run_id: UUID | None = None, ) -> None: - self._backend = backend + super().__init__(wrapped) self._db = db self._organization_id = organization_id self._session_key = session_key @@ -95,110 +98,102 @@ def __init__( # in hand at construction - without it every row said `run_id=null` and # "which execution did this" had no answer (the attribution #1061 is for). self.run_id = run_id + # Whether an operation found the session gone. Noted here because every + # operation passes through, and the close needs it: a lost session is + # forgotten, so the next run opens a fresh one instead of failing again. + self.lost = False + + async def read_bytes(self, path: str) -> bytes: + return await self._recorded( + "read", path, super().read_bytes(path), lambda data: (True, f"{len(data)} bytes") + ) - def __getattr__(self, name: str) -> Any: - attribute = getattr(self._backend, name) - if name not in _TARGET_ARG or not callable(attribute): - return attribute - # Transparent about *which* kind of callable it is, which is not a detail: - # a container's backend is synchronous and `_prune_spills` runs its - # `execute` through `asyncio.to_thread`. Return an async wrapper for it and - # `to_thread` hands back a coroutine nobody awaits - the command never runs, - # and its caller reads the missing `exit_code` as success. Recording needs - # no await of its own (`Session.add` is synchronous), so both shapes are - # available. - if inspect.iscoroutinefunction(attribute): - - async def recorded_async(*args: Any, **kwargs: Any) -> Any: - started = time.monotonic() - try: - result = await attribute(*args, **kwargs) - except Exception as exc: - self._failed(name, args, exc, time.monotonic() - started) - raise - self._finished(name, args, result, time.monotonic() - started) - return result - - return recorded_async - - def recorded(*args: Any, **kwargs: Any) -> Any: - started = time.monotonic() - try: - result = attribute(*args, **kwargs) - except Exception as exc: - self._failed(name, args, exc, time.monotonic() - started) - raise - self._finished(name, args, result, time.monotonic() - started) - return result - - return recorded - - def _failed(self, name: str, args: tuple[Any, ...], exc: Exception, elapsed: float) -> None: - self._record(name, args, ok=False, detail=exc.__class__.__name__, elapsed=elapsed) - - def _finished(self, name: str, args: tuple[Any, ...], result: Any, elapsed: float) -> None: - self._record( - name, - args, - ok=self._succeeded(result), - detail=self._detail(name, result), - elapsed=elapsed, + async def write_bytes(self, path: str, data: bytes) -> None: + await self._recorded( + "write", path, super().write_bytes(path, data), lambda _: (True, f"{len(data)} bytes") ) - @staticmethod - def _succeeded(result: Any) -> bool: - """Whether the backend's own answer says the operation worked. - - A `WriteResult` carries an `error`, and a write refused by a full document - answers rather than raising - so a log that read every non-exception as a - success would record a write that never happened as one that did. A - command's failure is its exit code: `false`, a failing compiler or a - refused script exit nonzero without raising and without an `error`, and - an audit log that painted those green would say the sandbox did what it - visibly did not. - """ - if getattr(result, "error", None) is not None: - return False - exit_code = getattr(result, "exit_code", None) - return exit_code is None or exit_code == 0 - - @staticmethod - def _detail(name: str, result: Any) -> str: - """One line about the outcome, written here and never quoted from below. - - A shell's own message *is* the command's output and an HTTP client's - carries the failing request, so neither may be stored (#423). What is - stored is a size or a count - the fact somebody auditing wants, which - happens also to be the fact that reveals nothing. + async def list_dir(self, path: str) -> Sequence[FileEntry]: + return await self._recorded( + "ls_info", path, super().list_dir(path), lambda found: (True, f"{len(found)} results") + ) + + async def make_dir(self, path: str) -> None: + await self._recorded("mkdir", path, super().make_dir(path), lambda _: (True, "")) + + async def remove(self, path: str) -> None: + await self._recorded("remove", path, super().remove(path), lambda _: (True, "")) + + async def run( + self, + command: WorkspaceCommand, + *, + shell: bool = False, + env: Mapping[str, str] | None = None, + timeout: float | None = None, + ) -> CommandResult: + return await self._recorded( + "execute", + _command_text(command), + super().run(command, shell=shell, env=env, timeout=timeout), + _command_outcome, + ) + + async def working_dir(self) -> str: + return await self._noting_loss(super().working_dir()) + + async def stat(self, path: str) -> FileEntry: + return await self._noting_loss(super().stat(path)) + + async def exists(self, path: str) -> bool: + return await self._noting_loss(super().exists(path)) + + async def realpath(self, path: str) -> str: + return await self._noting_loss(super().realpath(path)) + + async def _noting_loss(self, call: Awaitable[_T]) -> _T: + """Await a question about the workspace, noting a session found gone. + + Not recorded: these read no file and change nothing. But the console's + permission guard asks them before every read and write, so a session + purged mid-turn is often first seen here, and a close that missed it + would keep the lost session for the next turn to fail on again. """ - error = getattr(result, "error", None) - if error is not None: - return "refused" - exit_code = getattr(result, "exit_code", None) - if exit_code is not None and exit_code != 0: - # The numeric status is the one safe fact about a failed command - - # its output is the command's own text and stays out (#423). - return f"exit {exit_code}" - if name in {"read", "read_bytes"}: - return f"{len(result)} bytes" if hasattr(result, "__len__") else "" - if name in {"ls_info", "glob_info", "grep_raw"}: - return f"{len(result)} results" if hasattr(result, "__len__") else "" - return "" - - def _record( - self, op: str, args: tuple[Any, ...], *, ok: bool, detail: str, elapsed: float - ) -> None: - """Add the row. A failure to record never fails the operation. + try: + return await call + except WorkspaceUnavailableError: + self.lost = True + raise - Synchronous, because `Session.add` is - which is what lets the wrapper stay - transparent about whether the method it wraps was a coroutine function. + async def _recorded( + self, + op: str, + target: str, + call: Awaitable[_T], + outcome: Callable[[_T], tuple[bool, str]], + ) -> _T: + started = time.monotonic() + try: + result = await call + except Exception as exc: + if isinstance(exc, WorkspaceUnavailableError): + self.lost = True + # The class, never the message: a shell's message is the command's + # own output and an HTTP client's carries the failing request (#423). + self._record(op, target, ok=False, detail=exc.__class__.__name__, started=started) + raise + ok, detail = outcome(result) + self._record(op, target, ok=ok, detail=detail, started=started) + return result + + def _record(self, op: str, target: str, *, ok: bool, detail: str, started: float) -> None: + """Add the row. A failure to record never fails the operation. The log is an audit and the operation is the work: losing an entry is worth knowing about in a log line, and is not worth failing an agent's write for. """ from app.db.models.sandbox_operation import SandboxOperation - index = _TARGET_ARG[op] try: self._db.add( SandboxOperation( @@ -207,11 +202,22 @@ def _record( run_id=self.run_id, session_key=self._session_key, op=op, - target=_target(args[index]) if len(args) > index else "", + target=_target(target), detail=detail, ok=ok, - duration_ms=max(0, int(elapsed * 1000)), + duration_ms=max(0, int((time.monotonic() - started) * 1000)), ) ) except Exception: logger.warning("sandbox_operation_not_recorded", extra={"op": op}) + + +def _command_outcome(result: CommandResult) -> tuple[bool, str]: + """A command's failure is its exit code: `false`, a failing compiler or a + refused script exit nonzero without raising, and an audit log that painted + those green would say the sandbox did what it visibly did not. The numeric + status is the one safe fact about it - its output is the command's own text + and stays out (#423).""" + if result.exit_code == 0: + return True, "" + return False, f"exit {result.exit_code}" diff --git a/backend/app/agents/capabilities/subagents/_capability.py b/backend/app/agents/capabilities/subagents/_capability.py index a7d04f339..46618cfb8 100644 --- a/backend/app/agents/capabilities/subagents/_capability.py +++ b/backend/app/agents/capabilities/subagents/_capability.py @@ -401,8 +401,8 @@ class _LazyAgent: handed over as `SubAgentConfig["agent"]` would mean building all of them for every run that binds this capability. This stands in until one is needed. - *Run on the deps this platform decides, not the ones the library cloned.* See - `_own_deps`. + *Run on the deps and in the workspace this platform decides, not the ones the + library handed over.* See `_own_deps` and `_own_workspace`. *Continued rather than restarted, when this delegation already stopped for a person.* See `_continued`. Both entry points, because which one the library @@ -430,15 +430,15 @@ def run(self, *args: Any, **kwargs: Any) -> Any: """Run the delegate once, on its own deps. Returns the library's coroutine.""" resumed = self._journal.resuming() if resumed is None: - return self._built().run(*args, **self._own_deps(kwargs)) - return self._built().run(None, **self._own_deps(self._continuing(kwargs, resumed))) + return self._built().run(*args, **self._own_run(kwargs)) + return self._built().run(None, **self._own_run(self._continuing(kwargs, resumed))) def iter(self, *args: Any, **kwargs: Any) -> Any: """The same, for the streamed path. Returns the library's async context manager.""" resumed = self._journal.resuming() if resumed is None: - return self._built().iter(*args, **self._own_deps(kwargs)) - return self._built().iter(None, **self._own_deps(self._continuing(kwargs, resumed))) + return self._built().iter(*args, **self._own_run(kwargs)) + return self._built().iter(None, **self._own_run(self._continuing(kwargs, resumed))) @staticmethod def _continuing(kwargs: dict[str, Any], resumed: ResumedDelegation) -> dict[str, Any]: @@ -469,6 +469,25 @@ def _built(self) -> PydanticAgent[Any, Any]: self._agent = self._delegate.build() return self._agent + def _own_run(self, kwargs: dict[str, Any]) -> dict[str, Any]: + """The library's arguments, with what this platform decides put back.""" + return self._own_workspace(self._own_deps(kwargs)) + + def _own_workspace(self, kwargs: dict[str, Any]) -> dict[str, Any]: + """The workspace this delegation runs in, as `share_with_delegates` says. + + The library passes the parent's workspace to every delegation whenever one + is attached. Only a delegate the parent shared `sandbox` with keeps it; one + that binds `sandbox` itself gets a fresh in-memory workspace - `"new"`, so a + ref an earlier turn of it left in its history is not looked up either - and + one that binds none gets none. Left as the library has it, a delegate + nobody shared the sandbox with ran its own `execute` in the parent's + container. + """ + if self._delegate.workspace == "parent": + return kwargs + return {**kwargs, "workspace": "new" if self._delegate.workspace == "own" else None} + def _own_deps(self, kwargs: dict[str, Any]) -> dict[str, Any]: """The two fields this platform decides about the deps a delegation runs with. diff --git a/backend/app/agents/capabilities/system_reminders/README.md b/backend/app/agents/capabilities/system_reminders/README.md index fcbdccf74..6caaf30f4 100644 --- a/backend/app/agents/capabilities/system_reminders/README.md +++ b/backend/app/agents/capabilities/system_reminders/README.md @@ -27,9 +27,12 @@ never stored - only the counters are. ## Why injection is cache-safe A fired reminder is appended to the *tail* of the request as an ephemeral -`UserPromptPart` behind a `CachePoint`, inside `wrap_model_request` - which runs -after core has persisted the durable history. So the reminder reaches the model -but never enters `message_history`: no stale reminders pile up across turns, and +`UserPromptPart` behind a `CachePoint`, in `before_model_request`, by replacing the +tail in a new list rather than appending to it - pydantic-ai writes a before hook's +appends back to the history, and a replaced item it does not. The factory orders +this capability after every other configured one, so a compaction strategy that +rebuilds the request from the history runs first and cannot drop the reminder. So +the reminder reaches the model but never enters `message_history`: no stale reminders pile up across turns, and the cached prefix (tools, system, the real conversation) stays byte-identical turn over turn while only the small reminder falls outside the cache. Injecting into the system prompt instead would bust the cached prefix on every fire *and* pile up diff --git a/backend/app/agents/capabilities/system_reminders/_capability.py b/backend/app/agents/capabilities/system_reminders/_capability.py index 46ab43b73..acd0c9002 100644 --- a/backend/app/agents/capabilities/system_reminders/_capability.py +++ b/backend/app/agents/capabilities/system_reminders/_capability.py @@ -47,7 +47,6 @@ ModelMessage, ModelRequest, ModelRequestPart, - ModelResponse, RetryPromptPart, TextContent, TextPart, @@ -60,7 +59,6 @@ from app.agents.capabilities._ambient import AmbientCallSkipped, run_ambient_agent if TYPE_CHECKING: - from pydantic_ai.capabilities.abstract import WrapModelRequestHandler from pydantic_ai.models import ModelRequestContext logger = logging.getLogger(__name__) @@ -163,18 +161,20 @@ class SystemReminders(AbstractCapability[AgentDepsT]): state: ReminderState cache_ttl: Literal["5m", "1h"] = "5m" - async def wrap_model_request( + async def before_model_request( self, ctx: RunContext[AgentDepsT], - *, request_context: ModelRequestContext, - handler: WrapModelRequestHandler, - ) -> ModelResponse: - """Append fired reminders to the request tail, then call the model. - - Runs after core persists the durable history; the per-request message list - mutated here is never written back, so the reminder and its `CachePoint` - reach the model but never enter the conversation's history. + ) -> ModelRequestContext: + """Return the request with fired reminders appended to its tail. + + A before hook, and last in the chain - the factory orders this capability + after every other configured one - because a compaction strategy rebuilds + the request from the run's history in its own before hook: run before it, + the reminder would be dropped while its fire was already counted. The tail + message is replaced in a new list rather than appended to, so pydantic-ai + writes nothing back to the history: the reminder and its `CachePoint` reach + the model but never enter the conversation's history. """ messages = request_context.messages # A provider-resume turn hands back a message list whose tail is a @@ -193,8 +193,9 @@ async def wrap_model_request( if _has_user_content(last.parts): content.append(CachePoint(ttl=self.cache_ttl)) content.append("\n\n".join(texts)) - messages[-1] = replace(last, parts=[*last.parts, UserPromptPart(content=content)]) - return await handler(request_context) + tail = replace(last, parts=[*last.parts, UserPromptPart(content=content)]) + return replace(request_context, messages=[*messages[:-1], tail]) + return request_context async def _fire(self, ctx: RunContext[AgentDepsT]) -> list[str]: """The reminder texts to inject this request, committing their fire counts. diff --git a/backend/app/agents/capabilities/tool_output_limits/__init__.py b/backend/app/agents/capabilities/tool_output_limits/__init__.py index 56917a5f2..3d41b3401 100644 --- a/backend/app/agents/capabilities/tool_output_limits/__init__.py +++ b/backend/app/agents/capabilities/tool_output_limits/__init__.py @@ -7,7 +7,7 @@ CapabilityToolInfo, register, ) -from app.agents.capabilities.sandbox import WORKSPACE_BACKEND_RESOURCE +from app.agents.capabilities.sandbox import WORKSPACE_RESOURCE from app.agents.capabilities.tool_output_limits._capability import ( DEFAULT_MAX_CHARS, DEFAULT_SUMMARY_PROMPT, @@ -22,8 +22,8 @@ from app.agents.capabilities.tool_output_limits._store import ( OVERFLOW_PREFIX, SPILL_LOG_RESOURCE, - BackendOverflowStore, OverflowWriteError, + WorkspaceOverflowStore, ) __all__ = [ @@ -33,11 +33,11 @@ "OVERFLOW_PREFIX", "SPILL_LOG_RESOURCE", "ActionName", - "BackendOverflowStore", "MeteredToolOutputLimits", "OverflowWriteError", "StrategyName", "ToolOutputLimitsConfig", + "WorkspaceOverflowStore", "build_limits", ] @@ -65,15 +65,16 @@ def _build(ctx: CapabilityBuildContext) -> MeteredToolOutputLimits[object]: Always returns something: binding the capability *is* the decision to reduce, the way binding `compaction` is the decision to compact. The spill store is the - run's own backend when it bound `sandbox`, and an ephemeral in-memory one when + run's own workspace when it bound `sandbox`, and an ephemeral in-memory one when it did not - resolved from `resources`, never fetched here. """ config = ( ctx.config if isinstance(ctx.config, ToolOutputLimitsConfig) else ToolOutputLimitsConfig() ) - backend = ctx.resources.get(WORKSPACE_BACKEND_RESOURCE) return MeteredToolOutputLimits( wrapped=build_limits( - config, backend=backend, spill_log=ctx.resources.get(SPILL_LOG_RESOURCE) + config, + workspace=ctx.resources.get(WORKSPACE_RESOURCE), + spill_log=ctx.resources.get(SPILL_LOG_RESOURCE), ) ) diff --git a/backend/app/agents/capabilities/tool_output_limits/_capability.py b/backend/app/agents/capabilities/tool_output_limits/_capability.py index c4f01f4de..2d58bb233 100644 --- a/backend/app/agents/capabilities/tool_output_limits/_capability.py +++ b/backend/app/agents/capabilities/tool_output_limits/_capability.py @@ -51,7 +51,8 @@ from pydantic_ai.messages import ToolCallPart from pydantic_ai.tools import AgentDepsT, RunContext, ToolDefinition from pydantic_ai.toolsets import AgentToolset -from pydantic_ai_backends import StateBackend +from pydantic_ai.workspaces import Workspace +from pydantic_ai_backends import StateWorkspace from pydantic_ai_harness.tool_output_limits import ( READ_TOOL_NAME, Action, @@ -72,7 +73,7 @@ usage_counts, usage_delta, ) -from app.agents.capabilities.tool_output_limits._store import BackendOverflowStore +from app.agents.capabilities.tool_output_limits._store import WorkspaceOverflowStore DEFAULT_THRESHOLD = 60_000 """Characters at or above which a return is reduced, when a binding names none. @@ -250,38 +251,43 @@ def _action(config: ToolOutputLimitsConfig) -> Action: return Summarize(then=spill) -def _build_store(backend: Any, spill_log: list[str] | None = None) -> BackendOverflowStore: - """Where spills go: the run's own backend, or an ephemeral one built for it. +def _build_store( + workspace: Workspace | None, spill_log: list[str] | None = None +) -> WorkspaceOverflowStore: + """Where spills go: the run's own workspace, or an ephemeral one built for it. - An agent that binds `sandbox` has a backend the runner opened and keyed to the - organization; a spill lives and dies with that workspace, which on the default - `run` scope is exactly the run. On a longer-scoped workspace (`conversation`, - `user`, `agent`) a spill must not outlive the run either, and each backend - shape has its own mechanism: a `state` workspace has the reserved prefix - stripped at flush, so spills never enter the persisted document, and a - *container* workspace has this run's handles - recorded in `spill_log` - - deleted off its filesystem when the workspace closes (#803). + An agent that binds `sandbox` has a workspace the runner opened and keyed to + the organization; a spill lives and dies with it, which on the default `run` + scope is exactly the run. On a longer-scoped workspace (`conversation`, + `user`, `agent`) a spill must not outlive the run either, and each kind has + its own mechanism: a `state` workspace has the reserved prefix stripped at + flush, so spills never enter the persisted document, and a *container* + workspace has this run's handles - recorded in `spill_log` - deleted off its + filesystem when the workspace closes (#803). - An agent with no backend gets a fresh in-memory `StateBackend` here, so the - store is per-run and process-local rather than on shared disk. The fallback is + An agent with no workspace gets a fresh in-memory document here, so the store + is per-run and process-local rather than on shared disk. The fallback is uncapped: the store itself grows with each spill, but nothing is persisted and the run bounds how much it can accumulate before it is discarded whole. No - handles are recorded for it - there is nothing to delete from a backend that + handles are recorded for it - there is nothing to delete from a document that dies with the run. """ - if backend is None: - return BackendOverflowStore(StateBackend()) - return BackendOverflowStore(backend, spill_log=spill_log) + if workspace is None: + return WorkspaceOverflowStore(Workspace(StateWorkspace().backend())) + return WorkspaceOverflowStore(workspace, spill_log=spill_log) def build_limits( - config: ToolOutputLimitsConfig, *, backend: Any, spill_log: list[str] | None = None + config: ToolOutputLimitsConfig, + *, + workspace: Workspace | None, + spill_log: list[str] | None = None, ) -> ToolOutputLimits[object]: - """The harness capability this configuration asks for, spilling to `backend`.""" + """The harness capability this configuration asks for, spilling into `workspace`.""" return ToolOutputLimits( bands=[Band(over=config.threshold, action=_action(config))], over_tokens=config.over_tokens, - store=_build_store(backend, spill_log), + store=_build_store(workspace, spill_log), strip_ansi=config.strip_ansi, summary_prompt=config.summary_prompt, serializer=readable_return, diff --git a/backend/app/agents/capabilities/tool_output_limits/_store.py b/backend/app/agents/capabilities/tool_output_limits/_store.py index 37bce3487..169fb0881 100644 --- a/backend/app/agents/capabilities/tool_output_limits/_store.py +++ b/backend/app/agents/capabilities/tool_output_limits/_store.py @@ -1,37 +1,32 @@ -"""Where a spilled tool output is kept, and why it is the agent's own backend. +"""Where a spilled tool output is kept, and why it is the agent's own workspace. The harness `ToolOutputLimits` spills an oversized return through the narrow `OverflowStore` seam - `write(key, bytes) -> handle`, `read(handle) -> bytes` - -and reads it back on demand through `read_tool_result`. The harness default -writes to a shared temp directory and keeps the files forever, which is exactly -the wrong shape for a multi-tenant platform: two organizations would share one -root, and a spill would outlive the run that produced it with nothing scoped to -either tenant. - -So the spill goes to the run's *own* backend instead. An agent that binds -`sandbox` already has a filesystem - `state`, a Docker container, Daytona - that -the runner opened per run and keyed to the organization; :class:`BackendOverflowStore` -writes the spill there, under a reserved prefix, so it lives and dies with the -workspace the platform already governs and the agent can even reach it through -its own `read_file`/`grep` tools. An agent with no backend gets an ephemeral -`StateBackend` built for the run and discarded with it (see `_capability.py`), -which keeps the store per-run and process-local rather than on shared disk. - -Two backend shapes reach this, and both go through one adapter. A `state` -workspace is synchronous (`CappedStateBackend`); a container-backed one is -asynchronous (`AsyncBackendAdapter`). The protocol methods have the same -signature either way, so the adapter awaits a result only when it is awaitable -rather than branching on the backend type. +and reads it back on demand through `read_tool_result`. The harness's own stores +keep spills in a shared temp directory or in a metadata directory of the run's +workspace, and neither is the shape this platform needs: two organizations would +share one root, or the spills would land outside the prefix the platform strips +and prunes. + +So the spill goes to the run's *own* workspace, under a reserved prefix. An +agent that binds `sandbox` already has one - `state`, a container, Daytona - that +the runner opened per run and keyed to the organization; :class:`WorkspaceOverflowStore` +writes the spill there, so it lives and dies with the workspace the platform +already governs and the agent can even reach it through its own +`read_file`/`grep` tools. An agent with no workspace gets an in-memory document +built for the run and discarded with it (see `_capability.py`), which keeps the +store per-run and process-local rather than on shared disk. """ from __future__ import annotations -import inspect from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING + +from pydantic_ai.workspaces import WorkspaceError if TYPE_CHECKING: - from pydantic_ai_backends import AsyncBackendProtocol, BackendProtocol + from pydantic_ai.workspaces import Workspace # The directory spilled payloads live under, so they never collide with a file # the agent wrote and are obvious for what they are when it lists its workspace. @@ -52,7 +47,7 @@ class OverflowWriteError(Exception): - """The backend refused a spill - a `state` workspace already at its byte cap. + """The workspace refused a spill - a `state` workspace already at its byte cap. Raised so the harness `Spill` action catches it and falls back to its `then` (a bounded truncation), rather than losing the return outright. A spill that @@ -60,47 +55,34 @@ class OverflowWriteError(Exception): """ -async def _maybe_await(value: Any) -> Any: - """Await `value` when the backend was asynchronous, pass it through when not. - - The one place the sync `state` backend and an async container backend are - reconciled: both declare the same method signatures, so the call site is - identical and only the result differs. - """ - if inspect.isawaitable(value): - return await value - return value - - @dataclass -class BackendOverflowStore: - """An `OverflowStore` over a sandbox backend, sync or async. - - `write` returns the backend's own normalized path as the handle - a - `state` backend rewrites `tool_output/x` to `/tool_output/x`, and a later - `read` must use exactly what was stored. `read` guards on `exists` first - because the backends answer a missing path with empty bytes rather than an - error, and the harness read-back needs a raised `FileNotFoundError` to tell - the model the handle is unknown instead of handing it an empty slice. +class WorkspaceOverflowStore: + """An `OverflowStore` over the run's workspace. + + `write` returns the path the workspace resolves the spill to as the handle - + `/tool_output/x` in a stored document, `/workspace/tool_output/x` in a + container - so a later `read`, and the prune at close, name exactly the file + that was written. A handle the workspace does not hold raises + `FileNotFoundError`, which is what tells the model the handle is unknown. """ - backend: BackendProtocol | AsyncBackendProtocol + workspace: Workspace prefix: str = OVERFLOW_PREFIX spill_log: list[str] | None = None """Where written handles are recorded, when the run keeps a record at all. - `None` for the in-memory fallback backend, which is discarded with the run - and leaves nothing to delete.""" + `None` for the in-memory fallback, which is discarded with the run and leaves + nothing to delete.""" async def write(self, key: str, data: bytes) -> str: - result = await _maybe_await(self.backend.write(f"{self.prefix}/{key}", data)) - if result.error is not None: - raise OverflowWriteError(result.error) + handle = await self.workspace.resolve(f"{self.prefix}/{key}") + try: + await self.workspace.write_bytes(handle, data) + except (OSError, WorkspaceError) as refused: + raise OverflowWriteError(str(refused)) from refused if self.spill_log is not None: - self.spill_log.append(result.path) - return result.path + self.spill_log.append(handle) + return handle async def read(self, handle: str) -> bytes: - if not await _maybe_await(self.backend.exists(handle)): - raise FileNotFoundError(handle) - return await _maybe_await(self.backend.read_bytes(handle)) + return await self.workspace.read_bytes(handle) diff --git a/backend/app/agents/factory.py b/backend/app/agents/factory.py index 6e14aa916..daa8cd3bf 100644 --- a/backend/app/agents/factory.py +++ b/backend/app/agents/factory.py @@ -57,7 +57,11 @@ ) from app.agents.capabilities.memory_files import MEMORY_FILES_CAPABILITY_ID from app.agents.capabilities.memory_mem0 import MEMORY_MEM0_CAPABILITY_ID -from app.agents.capabilities.system_reminders import REMINDER_STATE_RESOURCE, ReminderState +from app.agents.capabilities.system_reminders import ( + REMINDER_STATE_RESOURCE, + ReminderState, + SystemReminders, +) from app.agents.connect_on_use import CONNECT_ACCOUNT, ConnectOnUse from app.agents.deps import AgentDeps, ApprovalCallback from app.agents.manifest import RecordingModel, RunRecorder @@ -270,6 +274,10 @@ def build_agent( }, secrets=secrets, ) + # Before hooks run in list order, and a compaction strategy rebuilds the + # request from the run's history in its own: reminders appended before it + # would be dropped (see `SystemReminders.before_model_request`). + configured.sort(key=lambda capability: isinstance(capability, SystemReminders)) # Which tools a human must approve before they act. Computed here, while the # registry metadata and the spec's overrides are both in hand, so a surface diff --git a/backend/app/agents/subagent_runtime.py b/backend/app/agents/subagent_runtime.py index dbc3976f5..cffb16fe2 100644 --- a/backend/app/agents/subagent_runtime.py +++ b/backend/app/agents/subagent_runtime.py @@ -8,7 +8,7 @@ tree while it still has a session and an auth context, and hands the capability this - closures it can call and data it can read, with no way to ask for more. -The same shape, and the same reason, as `WORKSPACE_BACKEND_RESOURCE`: opening a +The same shape, and the same reason, as `WORKSPACE_RESOURCE`: opening a workspace reads and writes rows, so the runner opens it and the capability receives what was opened. @@ -48,6 +48,21 @@ DelegationStatus = Literal["completed", "failed", "cancelled"] +DelegateWorkspace = Literal["parent", "own", "none"] +"""Which workspace a delegation runs in. + +`parent` - the delegating run's own, because `sandbox` is in its +`share_with_delegates`: a researcher writes `/workspace/notes.md` and a writer +reads it. `own` - a fresh in-memory one, for a delegate that binds `sandbox` +itself without being shared the parent's. `none` - no workspace, for a delegate +that binds no `sandbox` at all. + +Decided here rather than left to the delegation library, which hands every +delegation the parent's workspace whenever one is attached: the share list is +what decides who works in the parent's files, and with the library's default a +delegate nobody shared it with would run its own shell in them. +""" + @dataclass(frozen=True) class ResolvedSubagent: @@ -74,6 +89,8 @@ class ResolvedSubagent: preferred_mode: DelegationMode | None = None agent_id: UUID | None = None agent_version_id: UUID | None = None + workspace: DelegateWorkspace = "none" + """Which workspace this delegation runs in; see `DelegateWorkspace`.""" collection_names: tuple[str, ...] = () """The knowledge collections *this* delegate may search. diff --git a/backend/app/db/models/agent_workspace.py b/backend/app/db/models/agent_workspace.py index e82727e4b..ff5dff3ab 100644 --- a/backend/app/db/models/agent_workspace.py +++ b/backend/app/db/models/agent_workspace.py @@ -98,12 +98,15 @@ class AgentWorkspace(Base, TimestampMixin): """ session_id: Mapped[str | None] = mapped_column(String(64), nullable=True) - """The service-side session, for a container-backed workspace. - - Stored so that deleting a conversation can purge the sandbox that belongs to - it. Equal to `scope_key` today; kept as its own column because what the - service was told is a fact about the past, and re-deriving it after the key - format changes would purge the wrong session or none. + """The host session this container-backed workspace is, once one was opened. + + Recorded at the close of the run that opened it, not when the row is created: + a set value means "a session exists on the host", which is what the next run + attaches to - so a purged session surfaces as unavailable rather than being + silently replaced by an empty one - and what deleting the conversation purges. + `None` before the first session, and again after one was found gone. Kept as + its own column because what the host was told is a fact about the past, and + re-deriving it after the key format changes would reach the wrong session. """ files: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True) @@ -114,6 +117,14 @@ class AgentWorkspace(Base, TimestampMixin): and storing it as a blob would give up every query a dashboard might want. """ + directories: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True) + """The directories the `state` document created, beside `files`. + + A directory holding a file exists anyway; this keeps one `make_dir` made, or + one the agent emptied - as the library persists a document. Null for a row + from before it was kept, which loads as none created. + """ + bytes_total: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) """Size of `files`, maintained on write so a cap can be enforced without measuring the document on every read.""" diff --git a/backend/app/repositories/agent_workspace.py b/backend/app/repositories/agent_workspace.py index c161ff3f3..1c53caf66 100644 --- a/backend/app/repositories/agent_workspace.py +++ b/backend/app/repositories/agent_workspace.py @@ -35,7 +35,6 @@ async def create( backend: str, conversation_id: UUID | None = None, owner_ref: str | None = None, - session_id: str | None = None, connection_id: UUID | None = None, files: dict[str, Any] | None = None, ) -> AgentWorkspace: @@ -47,7 +46,6 @@ async def create( scope=scope, scope_key=scope_key, backend=backend, - session_id=session_id, connection_id=connection_id, files=files, bytes_total=0, @@ -65,6 +63,7 @@ async def save_files( *, workspace: AgentWorkspace, files: dict[str, Any], + directories: list[str], bytes_total: int, ) -> AgentWorkspace: """Store the document this run produced, bumping the version. @@ -81,6 +80,7 @@ async def save_files( can reach the race at all. """ workspace.files = files + workspace.directories = directories workspace.bytes_total = bytes_total workspace.version += 1 workspace.last_used_at = datetime.now(UTC) @@ -89,6 +89,16 @@ async def save_files( return workspace +async def record_session( + db: AsyncSession, *, workspace: AgentWorkspace, session_id: str | None +) -> AgentWorkspace: + """Record the host session this workspace is, or `None` once it is gone.""" + workspace.session_id = session_id + await db.flush() + await db.refresh(workspace) + return workspace + + async def touch(db: AsyncSession, *, workspace: AgentWorkspace) -> AgentWorkspace: """Record that a run opened this workspace without changing its files.""" workspace.last_used_at = datetime.now(UTC) diff --git a/backend/app/services/agent_chat.py b/backend/app/services/agent_chat.py index c1bd55462..e3b4ef792 100644 --- a/backend/app/services/agent_chat.py +++ b/backend/app/services/agent_chat.py @@ -443,7 +443,7 @@ async def run( if attachments: router = AttachmentRouter( - prepared.workspace.backend if prepared.workspace is not None else None, + prepared.workspace.workspace if prepared.workspace is not None else None, can_parse=prepared.workspace is not None and prepared.workspace.parses_documents, ) user_input = await router.build_prompt(_as_text(user_input), attachments) diff --git a/backend/app/services/agent_runner.py b/backend/app/services/agent_runner.py index 051fb1061..357ed574a 100644 --- a/backend/app/services/agent_runner.py +++ b/backend/app/services/agent_runner.py @@ -71,6 +71,7 @@ from pydantic_ai.run import AgentRunResult from pydantic_ai.tools import DeferredToolRequests, DeferredToolResults, ToolApproved from pydantic_ai.toolsets import AbstractToolset +from pydantic_ai.workspaces import Workspace from sqlalchemy.ext.asyncio import AsyncSession from app.agents.audience import RunAudience, derive_audience @@ -105,7 +106,7 @@ open_plan_store, still_open, ) -from app.agents.capabilities.sandbox import WORKSPACE_BACKEND_RESOURCE, WorkspaceIdentity +from app.agents.capabilities.sandbox import WORKSPACE_RESOURCE, WorkspaceIdentity from app.agents.capabilities.sandbox._identity import SessionScope from app.agents.capabilities.subagents import SubagentsConfig, acting_delegate from app.agents.capabilities.tool_output_limits import SPILL_LOG_RESOURCE @@ -134,6 +135,7 @@ ) from app.agents.subagent_runtime import ( SUBAGENT_RUNTIME_RESOURCE, + DelegateWorkspace, DelegationOutcome, DelegationRecorder, DelegationSpend, @@ -269,6 +271,23 @@ def _secret_ids(spec: AgentSpec) -> list[UUID]: return ids +def _delegate_workspace(spec: AgentSpec, shared: list[CapabilityBindingSpec]) -> DelegateWorkspace: + """Which workspace a delegate with this effective spec runs in. + + The parent's only when `sandbox` was shared - `_with_shared` has put the + parent's binding on `spec` then too, so the share list is what tells the two + apart. A delegate binding `sandbox` of its own works in a fresh one, and one + binding none in none. + """ + if any(binding.id == SANDBOX_CAPABILITY_ID for binding in shared): + return "parent" + if any( + binding.id == SANDBOX_CAPABILITY_ID and binding.enabled for binding in spec.capabilities + ): + return "own" + return "none" + + def _with_shared(spec: AgentSpec, shared: list[CapabilityBindingSpec]) -> AgentSpec: """The delegate's spec, plus the capabilities its caller shares with it. @@ -1170,6 +1189,7 @@ async def execute( message_history=message_history, deferred_tool_results=deferred_tool_results, usage_limits=self.built.usage_limits, + workspace=self._workspace(), ) @asynccontextmanager @@ -1208,9 +1228,20 @@ async def iterate( deps=self.built.deps, message_history=message_history, usage_limits=self.built.usage_limits, + workspace=self._workspace(), ) as iteration: yield iteration + def _workspace(self) -> Workspace | None: + """The workspace this run works in: the one the runner opened, if any. + + Passed rather than left to a capability, so it also overrides the ref a + stored history names - the previous turn's, or one restored from another + environment. `None` leaves the agent's own, the in-memory fallback of a + spec run without the runner. + """ + return None if self.workspace is None else self.workspace.workspace + def _outcome( agent_run: AgentIteration[AgentDeps, str | DeferredToolRequests], @@ -2378,7 +2409,7 @@ async def org_period_spend() -> Decimal: materialised: MaterialisedSkills | None = None started_with: set[str] | None = None if workspace is not None: - resources[WORKSPACE_BACKEND_RESOURCE] = workspace.backend + resources[WORKSPACE_RESOURCE] = workspace.workspace resources[SPILL_LOG_RESOURCE] = workspace.spills spec = _with_workspace_briefing(spec, workspace) # Skills as files, beside the shell that can run them. A skill whose @@ -2393,12 +2424,12 @@ async def org_period_spend() -> Decimal: # agent and the proposal flow use them, and not in a browser about what # an agent is keeping *for a person*. `browsable` is where they are # dropped instead (#1064). - materialised = await materialise_skills(workspace.backend, resources["skills"]) + materialised = await materialise_skills(workspace.workspace, resources["skills"]) if materialised.written: spec = _with_skills_briefing(spec) # After the skills are written, so materialising them does not read as # the turn's own output. - started_with = await workspace_snapshot(workspace.backend) + started_with = await workspace_snapshot(workspace.workspace) channel = ApprovalChannel( organization_id=ctx.organization_id, @@ -2832,6 +2863,7 @@ async def _resolve_specialist( ), max_steps=specialist.max_steps, preferred_mode=specialist.preferred_mode, + workspace=_delegate_workspace(spec, shared), collection_names=tuple(own_resources["kb_collection_names"]), ) @@ -3023,6 +3055,7 @@ async def _resolve_delegate( ), max_steps=runnable.max_steps, preferred_mode=ref.preferred_mode, + workspace=_delegate_workspace(runnable, shared), agent_id=ref.agent_id, agent_version_id=ref.agent_version_id, # Beside the agent as well as inside its deps, because the library @@ -3049,18 +3082,18 @@ async def _delegate_resources( precisely because it does not look like an agent. What does travel is the state a shared capability's instance depends on. - In practice that is one entry, the workspace backend, and it is the whole - point of sharing `sandbox`: without it a delegate builds a workspace of - its own and finds the file the parent wrote missing. `None` if the parent - opened none, which the capability answers with an in-memory workspace - exactly as it does for a preview. + For `sandbox` that is the workspace and the spill log, for the one + capability built before the run starts - the spill store of + `tool_output_limits` - so a delegate's spills land in the files it shares. + The delegation runs in the parent's workspace itself because + `ResolvedSubagent.workspace` says `parent` (`_delegate_workspace`). Two consequences worth stating, because both are silent. A delegate that binds `sandbox` itself *and* is shared the parent's gets its own tool configuration over the parent's session - sharing a workspace means sharing the files, and a delegate reading a different filesystem is the thing sharing exists to prevent. And a delegate that binds `sandbox` - without being shared one gets the in-memory workspace, because no + without being shared one gets a fresh in-memory workspace, because no workspace is opened per delegate: only the run has one. Sharing is how a delegate reaches a durable workspace at all. """ @@ -3070,7 +3103,7 @@ async def _delegate_resources( CONTEXT_FILES_RESOURCE: await self.context.resolve_for_agent(ctx, spec.context_ids), } if any(binding.id == SANDBOX_CAPABILITY_ID for binding in shared): - resources[WORKSPACE_BACKEND_RESOURCE] = parent_resources.get(WORKSPACE_BACKEND_RESOURCE) + resources[WORKSPACE_RESOURCE] = parent_resources.get(WORKSPACE_RESOURCE) # And the spill log with it: a delegate spilling to the shared # filesystem must record its handles where the workspace's close can # delete them (#803). @@ -3326,7 +3359,7 @@ async def _collect_outbound(prepared: PreparedRun) -> None: """ if prepared.workspace is None: return - delivered = await files_written(prepared.workspace.backend, prepared.workspace_at_start) + delivered = await files_written(prepared.workspace.workspace, prepared.workspace_at_start) prepared.outbound.extend(delivered.attachments) prepared.outbound_refused.extend(delivered.refused) @@ -3348,7 +3381,7 @@ async def _propose_skill_changes(self, prepared: PreparedRun) -> None: if workspace is None or state is None or prepared.ctx is None: return try: - changes = await collect_changes(workspace.backend, state) + changes = await collect_changes(workspace.workspace, state) if changes: await self.proposals.record( prepared.ctx, @@ -3676,7 +3709,7 @@ async def execute( assembled: str | list[Any] = prompt if attachments: assembled = await AttachmentRouter( - prepared.workspace.backend if prepared.workspace is not None else None, + prepared.workspace.workspace if prepared.workspace is not None else None, # Whether the workspace can read a PDF itself. Known, not inferred # from the briefing: a briefing is a best effort and this decides # whether the extracted text is written at all. diff --git a/backend/app/services/attachments.py b/backend/app/services/attachments.py index b41f626c5..3bf2ccd3c 100644 --- a/backend/app/services/attachments.py +++ b/backend/app/services/attachments.py @@ -28,7 +28,7 @@ from uuid import UUID from pydantic_ai.messages import BinaryContent -from pydantic_ai_backends import AsyncBackendProtocol, BackendProtocol, ensure_async +from pydantic_ai.workspaces import Workspace, WorkspaceError from app.core.blocking import run_blocking from app.core.config import settings @@ -372,18 +372,11 @@ class AttachmentRouter: def __init__( self, - backend: BackendProtocol | AsyncBackendProtocol | None = None, + workspace: Workspace | None = None, *, can_parse: bool = False, ) -> None: - # Wrapped here rather than at each `await` below, and rather than being the - # caller's problem. A container-backed workspace is a synchronous - # `httpx.Client`, so writing an upload into one from this coroutine blocked - # the whole worker for the length of the transfer - a 50 MB CSV stalling - # every other request in the process, which is the exact case a workspace - # exists to make possible. `ensure_async` is the library's own answer and is - # idempotent, so an already-async backend passes through untouched. - self._backend = None if backend is None else ensure_async(backend) + self._workspace = workspace # Whether the workspace can read a PDF, a `.docx` or a spreadsheet *itself*. # True only for a runtime this deployment describes, which is the same # condition as having told the model it has `lit` - so the caller passes @@ -439,7 +432,7 @@ async def build_prompt(self, user_message: str, files: list[ChatFile]) -> str | # a problem with the command it had just written and kept trying - in one # conversation `ls`, then a `curl` of a `data:` URI, then three workarounds # offered to the person, across two turns and 57k tokens (#1046). - if refused and self._backend is not None: + if refused and self._workspace is not None: text_parts.append(_WORKSPACE_REFUSED) full_text = user_message + "".join(text_parts) @@ -465,10 +458,10 @@ async def route( return AttachmentPlan(reference=_unprocessable(chat_file), inline=[]) async def _route(self, chat_file: ChatFile, inline_budget: int | None) -> AttachmentPlan: - backend = self._backend - if backend is None: + workspace = self._workspace + if workspace is None: return await self._without_workspace(chat_file, inline_budget) - return await self._into_workspace(backend, chat_file, inline_budget) + return await self._into_workspace(workspace, chat_file, inline_budget) async def _without_workspace( self, chat_file: ChatFile, inline_budget: int | None = None @@ -502,17 +495,18 @@ async def _without_workspace( async def _into_workspace( self, - backend: AsyncBackendProtocol, + workspace: Workspace, chat_file: ChatFile, inline_budget: int | None = None, ) -> AttachmentPlan: path = workspace_path(chat_file) data: bytes | None = None - if not await backend.exists(path): + if not await workspace.exists(path): data = await get_file_storage().load(chat_file.storage_path) - result = await backend.write(path, data) - if result.error is not None: + try: + await workspace.write_bytes(path, data) + except (OSError, WorkspaceError) as refused: # A full workspace, most likely - and that is exactly why this # must not fall back to pasting the file. The write is refused # when the document has no room for it, so this branch only ever @@ -524,7 +518,7 @@ async def _into_workspace( # An image is the exception, and the reason is the same one that # makes images go both ways: the model can still *see* it, and # `_inline_images` has its own, much smaller per-image ceiling. - logger.info("attachment_not_written", extra={"path": path, "reason": result.error}) + logger.info("attachment_not_written", extra={"path": path, "reason": str(refused)}) if chat_file.file_type == "image": plan = await self._without_workspace(chat_file) return AttachmentPlan( @@ -532,9 +526,9 @@ async def _into_workspace( ) return AttachmentPlan(reference=_unstored(chat_file), inline=[], refused=True) - await self._write_extracted_text(backend, chat_file, path) + await self._write_extracted_text(workspace, chat_file, path) - sibling = await self._sibling_present(backend, chat_file, path) + sibling = await self._sibling_present(workspace, chat_file, path) reference = _referenced(chat_file, path, sibling=sibling) if chat_file.file_type != "image": return AttachmentPlan(reference=reference, inline=[]) @@ -544,7 +538,7 @@ async def _into_workspace( return AttachmentPlan(reference=reference, inline=result.images) async def _sibling_present( - self, backend: AsyncBackendProtocol, chat_file: ChatFile, path: str + self, workspace: Workspace, chat_file: ChatFile, path: str ) -> str | None: """The extracted text beside the original, where the workspace really has it. @@ -560,7 +554,7 @@ async def _sibling_present( sibling = _text_sibling(chat_file, path) if sibling is None: return None - return sibling if await backend.exists(sibling) else None + return sibling if await workspace.exists(sibling) else None def _skips_sibling(self, chat_file: ChatFile) -> bool: """Whether the extracted-text sibling is redundant for this file. @@ -572,7 +566,7 @@ def _skips_sibling(self, chat_file: ChatFile) -> bool: return self._can_parse and chat_file.file_type in _LIT_READABLE async def _write_extracted_text( - self, backend: AsyncBackendProtocol, chat_file: ChatFile, path: str + self, workspace: Workspace, chat_file: ChatFile, path: str ) -> None: """Put the parse beside the original, for a workspace that cannot read it. @@ -587,13 +581,14 @@ async def _write_extracted_text( sibling = _text_sibling(chat_file, path) if sibling is None or not chat_file.parsed_content: return - result = await backend.write(sibling, chat_file.parsed_content) - if result.error is not None: + try: + await workspace.write_text(sibling, chat_file.parsed_content) + except (OSError, WorkspaceError) as refused: # Not raised and not reported to the model here: `_sibling_present` # asks the workspace what is actually there, so a refused write simply # goes unnamed. The line is what tells an operator why. logger.info( - "attachment_text_not_written", extra={"path": sibling, "reason": result.error} + "attachment_text_not_written", extra={"path": sibling, "reason": str(refused)} ) async def _inline_images( diff --git a/backend/app/services/capability_contracts.py b/backend/app/services/capability_contracts.py index bcc9ec3dd..c8560b4c2 100644 --- a/backend/app/services/capability_contracts.py +++ b/backend/app/services/capability_contracts.py @@ -29,6 +29,7 @@ from typing import Any from pydantic_ai import Agent as PydanticAgent +from pydantic_ai.capabilities import AbstractCapability, CombinedCapability, WrapperCapability from pydantic_ai.models.test import TestModel from pydantic_ai.tools import RunContext from pydantic_ai.usage import RunUsage @@ -141,15 +142,37 @@ def _documentation_secret(definition: Any) -> ApiKeySecret | None: return ApiKeySecret(api_key="documentation-probe") -def _probe_context() -> RunContext[AgentDeps]: - """A run context that reaches nothing. +def probe_context(built: AbstractCapability[Any]) -> RunContext[AgentDeps]: + """A run context that reaches nothing, for listing `built`'s tools. `get_tools` takes one because a capability may vary its list by the run - and none of them varies it by anything this carries. Deps are empty, so a capability reading them for a tenant or an audience sees the same "nothing" a run outside a conversation would. + + It does carry what a run registers: every capability in `built`'s tree. A + combination - the `sandbox` binding is a workspace and a console - attributes + each tool to the capability that owns it and looks that one up in the run, + and raises for a capability the run never registered. Public because the + registry's drift tests list tools exactly the way the catalog does. """ - return RunContext(deps=AgentDeps(), model=TestModel(), usage=RunUsage(), retry=0, max_retries=1) + registered: dict[str, AbstractCapability[Any]] = {} + pending: list[AbstractCapability[Any]] = [built] + while pending: + capability = pending.pop() + registered[f"probe-{len(registered)}"] = capability + if isinstance(capability, CombinedCapability): + pending.extend(capability.capabilities) + elif isinstance(capability, WrapperCapability): + pending.append(capability.wrapped) + return RunContext( + deps=AgentDeps(), + model=TestModel(), + usage=RunUsage(), + retry=0, + max_retries=1, + capabilities=registered, + ) @dataclass(frozen=True, slots=True) @@ -245,7 +268,7 @@ async def _contracts_for(definition: Any) -> dict[str, ToolContract]: # list after any filtering or wrapping the capability does - which is the # list the model is actually sent. contracts: dict[str, ToolContract] = {} - for tool_id, tool in (await toolset.get_tools(_probe_context())).items(): + for tool_id, tool in (await toolset.get_tools(probe_context(built))).items(): definition_for_model = real_tool_definition(tool) contracts[tool_id] = ToolContract( description=getattr(definition_for_model, "description", "") or "", diff --git a/backend/app/services/channels/attachments.py b/backend/app/services/channels/attachments.py index fbfdc7f52..a8e22fef0 100644 --- a/backend/app/services/channels/attachments.py +++ b/backend/app/services/channels/attachments.py @@ -28,10 +28,11 @@ import logging import mimetypes +import posixpath from dataclasses import dataclass from uuid import UUID -from pydantic_ai_backends import AsyncBackendProtocol, BackendProtocol, ensure_async +from pydantic_ai.workspaces import SupportsCommands, Workspace from sqlalchemy.ext.asyncio import AsyncSession from app.agents.capabilities.tool_output_limits import OVERFLOW_PREFIX @@ -235,9 +236,7 @@ def _why(attachment: IncomingAttachment, error: str | None) -> str: return error or "not supported." -async def files_written( - backend: BackendProtocol | AsyncBackendProtocol, before: set[str] | None -) -> DeliveredFiles: +async def files_written(workspace: Workspace, before: set[str] | None) -> DeliveredFiles: """What the agent produced this turn, ready to post. `before` is the set of paths the workspace held when the turn started, so this @@ -261,9 +260,8 @@ async def files_written( """ if before is None: return DeliveredFiles(attachments=[], refused=[]) - reader = ensure_async(backend) try: - paths = await _workspace_paths(reader) + paths = await _workspace_paths(workspace) except Exception: logger.warning("outbound_attachment_scan_failed", exc_info=True) return DeliveredFiles(attachments=[], refused=[]) @@ -273,13 +271,15 @@ async def files_written( refused: list[str] = [] for path in new: - if path.lstrip("/").startswith(_NOT_THE_AGENTS): + # Normalized first: a container's `find` answers `./tool_output/...`, which + # stripping the slash alone left looking like an agent's file. + if posixpath.normpath(path).lstrip("/").startswith(_NOT_THE_AGENTS): continue if len(attachments) >= MAX_OUTBOUND_FILES: refused.append(path) continue try: - data = await reader.read_bytes(path) + data = await workspace.read_bytes(path) except Exception: logger.info("outbound_attachment_unreadable", extra={"path": path}) continue @@ -304,9 +304,7 @@ async def files_written( return DeliveredFiles(attachments=attachments, refused=refused) -async def workspace_snapshot( - backend: BackendProtocol | AsyncBackendProtocol, -) -> set[str] | None: +async def workspace_snapshot(workspace: Workspace) -> set[str] | None: """Every path the workspace holds right now, or `None` if it could not be read. Taken before the turn so what it added can be told from what it already had. @@ -324,36 +322,42 @@ async def workspace_snapshot( one. """ try: - return await _workspace_paths(ensure_async(backend)) + return await _workspace_paths(workspace) except Exception: logger.warning("workspace_snapshot_failed", exc_info=True) return None -async def _workspace_paths(backend: AsyncBackendProtocol) -> set[str]: - """Every file in the workspace, dotfiles included. +async def _workspace_paths(workspace: Workspace) -> set[str]: + """Every file in the workspace's working directory, dotfiles included. - Two patterns, because `**/*` does not match a name beginning with a dot. Here it - matters in the *safe* direction and still matters: a `.env` the agent wrote before - the turn would be absent from the snapshot, so writing it again during the turn - would read as new and get posted into the channel. + One `find` where the workspace runs commands, so a container answers in a + single round trip rather than one per directory; a walk of its listings where + it does not, which for a stored document is a walk of memory. Either way a + dotfile is in it - a `.env` the agent wrote before the turn and wrote again + during it must not read as new and get posted into the channel. - Awaited rather than called: a container-backed workspace answers a glob over the - network with a synchronous client, so two of them from a coroutine held the event - loop for two round trips - once before the turn and once after. + **The root is the working directory, `.`.** `/` is the top of a document's + namespace and, to a shell, the machine: on a container a snapshot rooted + there was 2540 paths of `/proc` and `/usr`, taken twice per turn, so "what + did the agent write" was decided by whether `/proc` had changed (#1039). - **The root is named, and it is `.`** - the working directory. Omitting it took - the client's default of `/`, which a backend addressing files by virtual path - reads as the top of its namespace and a shell reads as the machine: on a - container this snapshot was 2540 paths of `/proc` and `/usr`, taken twice per - turn, so "what did the agent write" was decided by whether `/proc` had - changed. `pydantic-ai-backend` 0.2.27 makes the two agree, and saying `.` - here is also what keeps this correct against a service that has not been - updated yet (#1039). + Raises: + RuntimeError: The `find` failed - the callers turn that into "no snapshot". """ - return { - str(entry["path"]) - for pattern in ("**/*", "**/.*") - for entry in await backend.glob_info(pattern, ".") - if not entry.get("is_dir") - } + if isinstance(workspace.backend, SupportsCommands): + result = await workspace.run(["find", ".", "-type", "f"]) + if result.exit_code != 0: + raise RuntimeError(f"listing the workspace exited {result.exit_code}") + return {line for line in result.stdout.splitlines() if line} + return await _walk(workspace, await workspace.working_dir()) + + +async def _walk(workspace: Workspace, directory: str) -> set[str]: + paths: set[str] = set() + for entry in await workspace.list_dir(directory): + if entry.is_dir: + paths |= await _walk(workspace, entry.path) + else: + paths.add(entry.path) + return paths diff --git a/backend/app/services/sandbox_workspace.py b/backend/app/services/sandbox_workspace.py index 3196f32ff..c54fe120b 100644 --- a/backend/app/services/sandbox_workspace.py +++ b/backend/app/services/sandbox_workspace.py @@ -3,9 +3,9 @@ The capability cannot do this. Opening a workspace reads and writes the database — loading a stored document, recording which session belongs to which conversation — and a capability is built inside `build_agent`, which holds no -session and must not acquire one. So the runner opens one here, hands the -backend through `resources`, and closes it in the `finally` that already records -what the run cost. +session and must not acquire one. So the runner opens one here, passes the +workspace to the run - where every tool reaches it as `ctx.workspace` - and closes +it in the `finally` that already records what the run cost. What "closing" means differs by backend, and the difference is the reason this module exists rather than a helper on the capability: @@ -13,9 +13,11 @@ * `state` lives in this database. Closing flushes the document, and nothing survives that is not flushed. * `docker` lives in `sandboxd`, which owns its own lifecycle — idle reaping, - ceilings, hibernation. Closing releases a *run-scoped* session as a courtesy - and leaves every other scope alone, because correctness must not depend on - this process getting to its `finally`. + ceilings, hibernation. Closing releases a *run-scoped* session as a courtesy. + For a longer scope it records which session the row now holds, or forgets one + found gone, and removes this run's spills, but leaves the session itself to + `sandboxd`: correctness must not depend on this process getting to its + `finally`. * `daytona` is a cloud resource on the organization's own account, and the same applies with somebody else's invoice attached. """ @@ -27,7 +29,6 @@ import contextlib import contextvars import logging -import shlex from binascii import Error as BinasciiError from collections.abc import AsyncIterator, Callable, Coroutine, Sequence from concurrent.futures import ThreadPoolExecutor @@ -40,8 +41,14 @@ from uuid import UUID from PIL import Image, ImageOps -from pydantic_ai_backends import FileData, FileInfo -from pydantic_ai_backends.remote.client import DEFAULT_TIMEOUT_SECONDS +from pydantic_ai.workspaces import Workspace, WorkspaceRef +from pydantic_ai_backends import FileData, FileInfo, StateBackend +from pydantic_ai_backends.remote.archive import DEFAULT_TIMEOUT_SECONDS +from pydantic_ai_backends.workspaces import ( + DaytonaWorkspace, + SandboxdWorkspace, + StateWorkspaceBackend, +) from sqlalchemy.ext.asyncio import AsyncSession from app.agents.capabilities.sandbox import SandboxConfig @@ -53,7 +60,7 @@ WorkspaceScopeUnavailable, scope_key, ) -from app.agents.capabilities.sandbox._recording import RecordingBackend +from app.agents.capabilities.sandbox._recording import RecordingWorkspace from app.agents.capabilities.tool_output_limits import OVERFLOW_PREFIX from app.agents.spec import AgentSpec from app.core.config import settings @@ -72,6 +79,9 @@ SANDBOX_CAPABILITY_ID = "sandbox" +STATE_PROVIDER = "state" +"""`WorkspaceRef.provider` of a stored document, as `StateWorkspace` names it.""" + _MAX_LISTED_DEPTH = 6 """How deep a workspace listing walks. A directory is a round trip to the host.""" @@ -271,7 +281,11 @@ class FlatFileListing: class OpenWorkspace: """A workspace a run is using, and what it takes to put it away.""" - backend: Any + workspace: Workspace + """What the run works in. The runner passes it to the run as `workspace=`, + which is what every tool reaches as `ctx.workspace`, and stages skills and + attachments into it beforehand.""" + kind: BackendKind scope: SessionScope scope_key: str @@ -311,6 +325,12 @@ class OpenWorkspace: container-backed one, which keeps its files on the host rather than here. """ + document: CappedStateBackend | None = None + """The stored document a `state` workspace works in, flushed back at close.""" + + sandbox: SandboxdWorkspace | DaytonaWorkspace | None = None + """What supplies a container-backed workspace, for removing a `run`-scoped one.""" + spills: list[str] = field(default_factory=list) """Every spill handle `tool_output_limits` wrote to this workspace this run. @@ -336,29 +356,30 @@ def sandbox_config(spec: AgentSpec) -> SandboxConfig | None: return None -def _without_spills(files: dict[str, FileData]) -> dict[str, FileData]: - """The workspace document with `tool_output_limits` spills stripped out (#803). +def _provider(sandbox: SandboxdWorkspace | DaytonaWorkspace) -> str: + """The provider name the refs of `sandbox`'s workspaces carry.""" + return sandbox.provider if isinstance(sandbox, SandboxdWorkspace) else "daytona" + - A spilled tool return goes to the run's backend under the reserved +def _is_spill(path: str) -> bool: + """Whether a path in the stored document is a `tool_output_limits` spill (#803). + + A spilled tool return goes to the run's workspace under the reserved `tool_output/` prefix so `read_tool_result` can page through it, but it is a within-run artefact and must never survive into the persisted document. On a longer-scoped state workspace it otherwise accumulates every run and counts against `SANDBOX_STATE_MAX_BYTES`, until the cap starts refusing the agent's own writes. Stripping it at flush keeps the invariant "the stored document holds the agent's files, never its spills", every run self-healing whatever a - prior one left. Backend keys are normalized with a leading slash; the + prior one left. Document keys are normalized with a leading slash; the `lstrip` tolerates both forms. """ + relative = path.lstrip("/") + return relative == OVERFLOW_PREFIX or relative.startswith(f"{OVERFLOW_PREFIX}/") - def _is_spill(path: str) -> bool: - relative = path.lstrip("/") - return relative == OVERFLOW_PREFIX or relative.startswith(f"{OVERFLOW_PREFIX}/") - - return {path: data for path, data in files.items() if not _is_spill(path)} - -def _without_legacy_skills(files: dict[str, FileData]) -> dict[str, FileData]: - """The same files without the skills tree that predates the move inside the workspace. +def _is_legacy_skill(path: str) -> bool: + """Whether a path is in the skills tree that predates the move inside the workspace. A conversation-, user- or agent-scoped `state` workspace created before skills moved to `SKILLS_ROOT` still holds them under `LEGACY_SKILLS_ROOT`, and nothing @@ -373,9 +394,8 @@ def _without_legacy_skills(files: dict[str, FileData]) -> dict[str, FileData]: `workspace/skills/...`, which is not under the legacy prefix. """ legacy = LEGACY_SKILLS_ROOT.strip("/") - return { - path: data for path, data in files.items() if not path.lstrip("/").startswith(f"{legacy}/") - } + relative = path.lstrip("/") + return relative == legacy or relative.startswith(f"{legacy}/") def _under_overflow(path: str) -> bool: @@ -493,16 +513,20 @@ async def _open_state( key: str, scope: SessionScope, ) -> OpenWorkspace: - from pydantic_ai_backends import StateBackend - row = await self._row(config, identity, key, scope) - stored = dict(row.files or {}) if row is not None else {} - backend = CappedStateBackend( - StateBackend(files=stored), # type: ignore[arg-type] + document = CappedStateBackend( + dict(row.files or {}) if row is not None else {}, + row.directories or () if row is not None else (), max_bytes=settings.SANDBOX_STATE_MAX_BYTES, ) + # Served under the scope key, so a history that names it - the ref the + # last run recorded - names this very document. + backend = StateWorkspaceBackend( + {key: document}, ref=WorkspaceRef(provider=STATE_PROVIDER, id=key) + ) return OpenWorkspace( - backend=backend, + workspace=Workspace(backend), + document=document, kind="state", scope=scope, scope_key=key, @@ -520,9 +544,17 @@ async def _open_service( ) -> OpenWorkspace: """A workspace on one of the organization's registered connections. - Nothing starts here. `RemoteSandbox` opens its session on the first - operation, so an agent granted a workspace it never touches costs no - container and not even a round trip. + Nothing starts here. The workspace opens its session on the first + operation - under the scope key, attaching when it exists - so an agent + granted a workspace it never touches costs no container and not even a + round trip. + + **Once a session was opened, a later run only attaches.** The row records + the session at close (`_settle_session`), and from then on the workspace + is opened by that ref, which the provider answers with the session or with + `WorkspaceUnavailableError` - never with a new, empty one under the same + name. A conversation whose files were purged on the host is told so, + rather than carrying on in an empty directory as if nothing happened. The connection arrives resolved rather than being read from settings, which is what makes two hosts possible and what keeps the credential in @@ -532,24 +564,27 @@ async def _open_service( """ briefing: str | None = None parses_documents = False - if resolved.kind == "daytona": - backend = self._daytona(key, resolved) - else: - backend = self._sandboxd(config, identity, key, resolved) + sandbox = self._service_capability( + resolved, key, runtime=config.runtime, tenant=str(identity.organization_id) + ) + if resolved.kind != "daytona": alias = config.runtime or resolved.row.default_runtime or None briefing = runtime_briefing(alias) parses_documents = runtime_parses_documents(alias) - row = await self._row( - config, identity, key, scope, session_id=key, connection_id=resolved.row.id + row = await self._row(config, identity, key, scope, connection_id=resolved.row.id) + opened = ( + None + if row is None or row.session_id is None + else WorkspaceRef(provider=_provider(sandbox), id=row.session_id) ) # Wrapped so what the agent does in this sandbox is recorded on our side. # The service keeps its own log and it is 200 entries in that process's # memory, gone on restart, so a sandbox that outlives the service's uptime # had no audit at all (#1061). Wrapping here rather than calling from each # tool is what makes it impossible to forget for the ninth tool. - recorder = RecordingBackend( - backend, + recorder = RecordingWorkspace( + Workspace(sandbox.backend(opened)), db=self.db, organization_id=identity.organization_id, session_key=key, @@ -557,7 +592,8 @@ async def _open_service( run_id=identity.run_id, ) return OpenWorkspace( - backend=recorder, + workspace=recorder, + sandbox=sandbox, kind="service", scope=scope, scope_key=key, @@ -568,42 +604,54 @@ async def _open_service( ) @staticmethod - def _sandboxd( - config: SandboxConfig, - identity: WorkspaceIdentity, - key: str, + def _service_capability( resolved: ResolvedConnection, - ) -> Any: - from pydantic_ai_backends.remote import RemoteSandbox - - return RemoteSandbox( - resolved.row.base_url or "", + key: str, + *, + runtime: str | None = None, + tenant: str | None = None, + ) -> SandboxdWorkspace | DaytonaWorkspace: + """What supplies the sandbox named `key` on one of the organization's connections. + + Named by the key rather than by the provider: the key is where "who shares + files with whom" became mechanical (`scope_key`), so every run of a scope + opens the same session - and the first of two concurrent ones creates it + while the other attaches - without this database recording a provider's + id first. + """ + if resolved.kind == "daytona": + from daytona import DaytonaConfig + + # The organization's own key, from the connection, from the vault. Never + # the SDK's `DAYTONA_API_KEY` fallback, which would put every tenant's + # sandboxes on whichever account the deployment happens to have set. + return DaytonaWorkspace(config=DaytonaConfig(api_key=resolved.token), sandbox_name=key) + if not resolved.row.base_url: + # Without an address the client would post the organization's token + # at whatever a relative URL resolves to. + raise BadRequestError( + message="This sandbox connection has no address.", + details={"connection_id": str(resolved.row.id)}, + ) + return SandboxdWorkspace( + service_url=resolved.row.base_url, token=resolved.token, - session_id=key, + # One provider name per connection, so a ref from one host is never + # read as a session on another. + provider=f"sandboxd:{resolved.row.id}", # The spec's alias wins, then the connection's default, then whatever # the service itself defaults to. Three levels because each answers a # different question: what this agent needs, what this host prefers, # and what exists at all. - runtime=config.runtime or resolved.row.default_runtime, + runtime=runtime or resolved.row.default_runtime, # The organization, as a capacity label the service counts against # its per-tenant ceiling. It grants nothing - only the service token # opens a session at all - and it is what stops one talkative # organization occupying the pool of the whole installation. - tenant=str(identity.organization_id), - # Every scope but `run` is meant to be returned to, and even `run` - # reattaches within a turn when two tool calls race to open it. - reuse=True, + tenant=tenant, + session_name=key, ) - @staticmethod - def _daytona(key: str, resolved: ResolvedConnection) -> Any: - from pydantic_ai_backends import DaytonaSandbox - - # The organization's own key, from the connection, from the vault. Never - # the SDK's `DAYTONA_API_KEY` fallback, which would put every tenant's - # sandboxes on whichever account the deployment happens to have set. - return DaytonaSandbox(api_key=resolved.token, sandbox_id=key) - async def _row( self, config: SandboxConfig, @@ -611,7 +659,6 @@ async def _row( key: str, scope: SessionScope, *, - session_id: str | None = None, connection_id: UUID | None = None, ) -> AgentWorkspace | None: """The bookkeeping row for this workspace, created on first use. @@ -638,7 +685,6 @@ async def _row( scope=scope, scope_key=key, backend=config.backend, - session_id=session_id, connection_id=connection_id, ) @@ -666,7 +712,7 @@ async def close(self, workspace: OpenWorkspace | None, *, keep_spills: bool = Fa await self._flush_state(workspace, keep_spills=keep_spills) elif workspace.scope == "run": await self._release(workspace) - elif not keep_spills: + elif not await self._settle_session(workspace) and not keep_spills: await self._prune_spills(workspace) except Exception: logger.exception("workspace_close_failed", extra={"scope_key": workspace.scope_key}) @@ -686,15 +732,28 @@ async def _flush_state(self, workspace: OpenWorkspace, *, keep_spills: bool) -> # The conversation was deleted while the run was in flight. The files # belonged to it, so there is nothing to keep. return - self._warn_if_overtaken(workspace, row) - kept = workspace.backend.files if keep_spills else _without_spills(workspace.backend.files) - files = _without_legacy_skills(kept) + document = workspace.document + if document is None: # pragma: no cover - `open` sets one on every `state` workspace + raise RuntimeError("A state workspace was opened without its document") + self._warn_if_overtaken(workspace, row, document.files) + + def dropped(path: str) -> bool: + return _is_legacy_skill(path) or (not keep_spills and _is_spill(path)) + + files = {path: data for path, data in document.files.items() if not dropped(path)} + directories = sorted(path for path in document.directories if not dropped(path)) await workspace_repo.save_files( - self.db, workspace=row, files=files, bytes_total=document_size(files) + self.db, + workspace=row, + files=files, + directories=directories, + bytes_total=document_size(files, directories), ) @staticmethod - def _warn_if_overtaken(workspace: OpenWorkspace, row: AgentWorkspace) -> None: + def _warn_if_overtaken( + workspace: OpenWorkspace, row: AgentWorkspace, files: dict[str, FileData] + ) -> None: """Say so when this flush is about to overwrite somebody else's. The write still happens - see `save_files` for why refusing it would lose @@ -708,7 +767,7 @@ def _warn_if_overtaken(workspace: OpenWorkspace, row: AgentWorkspace) -> None: opened = workspace.opened_version if opened is None or row.version == opened: return - overwritten = sorted(set(row.files or {}) - set(workspace.backend.files)) + overwritten = sorted(set(row.files or {}) - set(files)) logger.warning( "workspace_flush_overtaken", extra={ @@ -720,6 +779,60 @@ def _warn_if_overtaken(workspace: OpenWorkspace, row: AgentWorkspace) -> None: }, ) + async def _settle_session(self, workspace: OpenWorkspace) -> bool: + """Record which session this workspace is now, and whether it was lost. + + A session the run opened is recorded on the row, so the next run attaches + to it rather than opening one by name - which is what lets a purged one + be noticed at all. A session the run found gone is forgotten, so the next + run starts a fresh one: this run has already reported the loss, through + the tool or the staging that hit it, and the turn after it should not hit + it again. + + Returns: + Whether the session was lost, in which case there is nothing on the + host to prune. + """ + row = ( + None + if workspace.row_id is None + else await self.db.get(AgentWorkspace, workspace.row_id, populate_existing=True) + ) + recorder = workspace.workspace + lost = isinstance(recorder, RecordingWorkspace) and recorder.lost + if row is None: + return lost + if lost: + logger.warning( + "workspace_session_lost", + extra={"scope_key": workspace.scope_key, "session_id": row.session_id}, + ) + if isinstance(workspace.sandbox, DaytonaWorkspace): + await self._discard_lost_sandbox( + workspace.sandbox, row.session_id or workspace.scope_key + ) + await workspace_repo.record_session(self.db, workspace=row, session_id=None) + return True + ref = workspace.workspace.ref + if ref is not None and ref.id != row.session_id: + await workspace_repo.record_session(self.db, workspace=row, session_id=ref.id) + return False + + async def _discard_lost_sandbox(self, sandbox: DaytonaWorkspace, name: str) -> None: + """Delete a Daytona sandbox found gone, so the next run can open its name afresh. + + A purged container is gone from its host. A Daytona sandbox in `error` or + `build_failed` is not: it still holds the scope's name, the next run opening + by that name would attach to it and be refused again, turn after turn, and + nothing else deletes it. Best effort, because the loss is already reported. + """ + try: + await sandbox.destroy(WorkspaceRef(provider=_provider(sandbox), id=name)) + except Exception: + logger.warning( + "workspace_lost_sandbox_not_deleted", extra={"sandbox": name}, exc_info=True + ) + async def _prune_spills(self, workspace: OpenWorkspace) -> None: """Delete this run's spilled tool returns off a workspace that outlives it. @@ -730,43 +843,37 @@ async def _prune_spills(self, workspace: OpenWorkspace) -> None: the overflow store recorded. Another run's spills are untouched - its handles are in its own workspace's list. - Deleted through the backend's own `execute`, because the backend protocol - has no delete and growing one belongs upstream. The `rmdir` afterwards - clears the now-empty run directories; it fails silently where a concurrent - run still keeps files, which is the correct answer - which is why the - command exits with `rm`'s status, captured before the `rmdir`: a refused - `rm` is the failure the warning below exists for, and a trailing cleanup - must not mask it as success. Best-effort like `_release`: a run that - crashes before its `finally` leaves its spills for the next manual sweep, - and `close` already logs whatever raises here. + Two commands rather than a removal per handle, so a hundred spills are two + round trips, and argv rather than a shell line, so no path is ever parsed + as one. The `rmdir` afterwards clears the now-empty run directories; it + fails where a concurrent run still keeps files, which is the correct + answer - so only a refused `rm` is the failure the warning below exists + for. Best-effort like `_release`: a run that crashes before its `finally` + leaves its spills for the next manual sweep, and `close` already logs + whatever raises here. """ handles = [handle for handle in workspace.spills if _under_overflow(handle)] if not handles: return - execute = getattr(workspace.backend, "execute", None) - if execute is None: - return directories = sorted( {parent for handle in handles for parent in _overflow_parents(handle)}, key=lambda directory: directory.count("/"), reverse=True, ) - files = " ".join(shlex.quote(handle) for handle in handles) - emptied = " ".join(shlex.quote(directory) for directory in directories) - command = f"rm -f -- {files}; status=$?; rmdir -- {emptied} 2>/dev/null; exit $status" - result = await asyncio.to_thread(execute, command) - if getattr(result, "exit_code", 0) != 0: + result = await workspace.workspace.run(["rm", "-f", "--", *handles]) + await workspace.workspace.run(["rmdir", "--", *directories]) + if result.exit_code != 0: logger.warning( "workspace_spill_prune_failed", extra={ "scope_key": workspace.scope_key, "handles": len(handles), - "output": getattr(result, "output", ""), + "output": result.stderr, }, ) async def _release(self, workspace: OpenWorkspace) -> None: - """Stop a run-scoped sandbox, as a courtesy rather than a guarantee. + """Remove a run-scoped sandbox, as a courtesy rather than a guarantee. `sandboxd` reaps idle sessions on its own, which is what makes this safe to be best-effort for a container: a run that crashes between opening a @@ -777,19 +884,10 @@ async def _release(self, workspace: OpenWorkspace) -> None: organization's own account, so the courtesy is the only thing that ends it, which is why the call has to actually land. """ - stop = getattr(workspace.backend, "stop", None) - if stop is None: - return - # One signature across every backend as of pydantic-ai-backend 0.2.25 - # (vstorm-co/pydantic-ai-backend#98). Before it, `RemoteSandbox.stop` took - # `purge` and `DaytonaSandbox.stop` took nothing, so this call raised a - # `TypeError` that `close` swallowed as `workspace_close_failed` - and the - # one backend with no idle reaper behind it was the one never released, a - # sandbox per run on somebody's invoice. An `inspect.signature` check stood - # here until the library stopped needing one. - # - # Off the loop: both are synchronous HTTP to somebody else. - await asyncio.to_thread(stop, purge=True) + ref = workspace.workspace.ref + if workspace.sandbox is None or ref is None: + return # never opened: nothing to remove + await workspace.sandbox.destroy(ref) async def purge_for_conversation(self, ctx: AuthContext, *, conversation_id: UUID) -> int: """Drop every workspace belonging to a conversation being deleted. @@ -823,41 +921,12 @@ async def _purge_remote(self, ctx: AuthContext, row: AgentWorkspace) -> None: try: resolved = await self._connection(ctx, row.connection_id) - if resolved.kind == "daytona": - from pydantic_ai_backends import DaytonaSandbox - - # The organization's own key, as everywhere else - never the SDK's - # `DAYTONA_API_KEY` fallback. `purge` is accepted and makes no - # difference here: Daytona has no "end it but keep the files" state, - # so stopping is deleting. Passed anyway, because this call site is - # about discarding the conversation's workspace and saying so is - # better than relying on the default meaning the same thing. - cloud = DaytonaSandbox( - api_key=resolved.token, sandbox_id=row.session_id or row.scope_key - ) - await asyncio.to_thread(cloud.stop, purge=True) - return - - from pydantic_ai_backends.remote import RemoteSandbox - - # Only the address is checked. The kind used to be too, which is how - # Daytona fell through here and was never purged at all; now that it - # returns above, `docker` is the only kind left - a connection is one - # or the other - and an address it has no value for is the one thing - # still worth refusing, because `RemoteSandbox("")` would post the - # organization's token at whatever a relative URL resolves to. - if not resolved.row.base_url: - return - sandbox = RemoteSandbox( - resolved.row.base_url, - token=resolved.token, - session_id=row.session_id or row.scope_key, - reuse=True, + sandbox = self._service_capability(resolved, row.scope_key) + # By the name it was opened under; attach-only, so a session already + # gone is fine rather than one opened just to delete it. + await sandbox.destroy( + WorkspaceRef(provider=_provider(sandbox), id=row.session_id or row.scope_key) ) - # A synchronous `DELETE`, so off the loop - and this runs in a loop over - # every workspace the conversation held, which is where one blocking - # round trip becomes several. - await asyncio.to_thread(sandbox.stop, purge=True) except Exception: # A service that is down must not stop a user deleting their chat. # The workspace TTL is the net under exactly this. @@ -1546,12 +1615,11 @@ async def _read_from(self, ctx: AuthContext, row: AgentWorkspace, path: str) -> @staticmethod def _state_bytes(row: AgentWorkspace, path: str) -> bytes | None: """One file's bytes out of a state-backed workspace, or None when absent.""" - from pydantic_ai_backends import StateBackend - - backend = StateBackend(files=dict(row.files or {})) - if not backend.exists(path): + document = StateBackend(files=dict(row.files or {}), directories=row.directories or ()) + try: + return document.read_bytes(path) + except (FileNotFoundError, IsADirectoryError, NotADirectoryError): return None - return backend.read_bytes(path) @asynccontextmanager async def _archive( @@ -1613,14 +1681,10 @@ async def _connection(self, ctx: AuthContext, connection_id: UUID) -> ResolvedCo def _absent(row: AgentWorkspace, contents: WorkspaceContents, path: str) -> bool: """Whether a listing that could be read says this path is not in it. - Only asked of a workspace kept on a host. A stored one has an authoritative - oracle - `StateBackend.exists` - and using a *listing* as one there is wrong: - `glob_info` does not match dotfiles, so `/.env` exists, reads fine, and is not - in any listing. Answering "no such file" for it would be a confident wrong - answer built on a pattern's blind spot. - - An unreadable listing also answers `False`: it knows nothing, and the same - argument applies. + Only asked of a workspace kept on a host. A stored one answers from its own + document, so a listing is never its oracle. An unreadable listing answers + `False`: it knows nothing, and a "no such file" built on it would be a + confident wrong answer. """ if row.backend == "state": return False @@ -1632,24 +1696,27 @@ def _absent(row: AgentWorkspace, contents: WorkspaceContents, path: str) -> bool def stored_entries(files: dict[str, FileData]) -> list[FileInfo]: """Every file in a stored workspace, dotfiles included. - Two patterns, because one is not enough: `**/*` does not match a name beginning - with a dot, so an agent that wrote `/​.env` or `/​.gitignore` had it absent from - every listing - the chat panel, the browser, the flat view - while `read` served - it happily. A listing that claims to be "what the agent is keeping" cannot quietly - omit a class of filename. + Read from the document's own keys rather than a glob, so `/​.env` and `/​.gitignore` are + listed like any other file: a listing that claims to be "what the agent is + keeping" cannot quietly omit a class of filename. - A file *inside* a dot-directory (`/​.git/config`) is still absent, and that is the - one omission worth keeping: an agent that ran `git init` would otherwise fill the + A file *inside* a dot-directory (`/​.git/config`) is left out, and that is the one + omission worth keeping: an agent that ran `git init` would otherwise fill the panel with object files nobody asked to see. """ - from pydantic_ai_backends import StateBackend - - backend = StateBackend(files=files) - seen: dict[str, FileInfo] = {} - for pattern in ("**/*", "**/.*"): - for entry in backend.glob_info(pattern): - seen[str(entry.get("path"))] = entry - return sorted(seen.values(), key=lambda entry: str(entry.get("path"))) + document = StateBackend(files=files) + entries = [ + FileInfo( + name=PurePosixPath(path).name, + path=path, + is_dir=False, + size=document.size(path), + modified_at=data.get("modified_at"), + ) + for path, data in files.items() + if not any(part.startswith(".") for part in PurePosixPath(path).parent.parts) + ] + return sorted(entries, key=lambda entry: entry["path"]) PREVIEW_CHARS = 200 diff --git a/backend/app/services/skill_workspace.py b/backend/app/services/skill_workspace.py index 965da931d..56b35028d 100644 --- a/backend/app/services/skill_workspace.py +++ b/backend/app/services/skill_workspace.py @@ -36,7 +36,7 @@ from dataclasses import dataclass, field from typing import Any -from pydantic_ai_backends import AsyncBackendProtocol, BackendProtocol, ensure_async +from pydantic_ai.workspaces import Workspace from app.db.models.skill import Skill from app.services.skill_library import split_frontmatter @@ -125,22 +125,19 @@ def render_body(skill: Skill) -> str: return f"---\nname: {skill.name}\ndescription: {skill.description}\n---\n\n{skill.content}\n" -async def materialise( - backend: BackendProtocol | AsyncBackendProtocol, skills: list[Skill] -) -> MaterialisedSkills: +async def materialise(workspace: Workspace, skills: list[Skill]) -> MaterialisedSkills: """Write each skill into the workspace, and remember what was written. Never raises. A workspace that refuses a write - past its storage ceiling, holding a path the backend rejects - must not stop the run: the skills are still in the prompt, which is how they worked before this existed. - Awaited, and that matters more here than anywhere else this wraps a backend. - This runs inside `prepare`, once per file per skill, before the model has seen - a token - so on a container-backed workspace an agent with five skills and - three resources each paid fifteen synchronous round trips on the event loop - before its first word. + Awaited, and that matters more here than anywhere else: this runs inside + `prepare`, once per file per skill, before the model has seen a token - so on + a container-backed workspace an agent with five skills and three resources + each pays fifteen round trips before its first word, and they must not block + the event loop. """ - writer = ensure_async(backend) state = MaterialisedSkills() for skill in skills: state.owners[skill.name] = skill.id @@ -148,28 +145,26 @@ async def materialise( for resource in skill.resources: files[f"{skill_dir(skill.name)}/{resource.name}"] = resource.content for path, content in files.items(): - if await _write(writer, path, content): + if await _write(workspace, path, content): state.written[path] = content return state -async def _write(backend: AsyncBackendProtocol, path: str, content: str) -> bool: +async def _write(workspace: Workspace, path: str, content: str) -> bool: """Whether the file made it. A refusal is logged rather than raised.""" try: - result = await backend.write(path, content) + await workspace.write_text(path, content) + except OSError as refused: + # The workspace said no - past its storage ceiling, a path it rejects. + logger.warning("skill_materialise_refused", extra={"path": path, "reason": str(refused)}) + return False except Exception: logger.warning("skill_materialise_failed", extra={"path": path}, exc_info=True) return False - error = getattr(result, "error", None) - if error: - logger.warning("skill_materialise_refused", extra={"path": path, "reason": error}) - return False return True -async def collect_changes( - backend: BackendProtocol | AsyncBackendProtocol, state: MaterialisedSkills -) -> list[SkillChange]: +async def collect_changes(workspace: Workspace, state: MaterialisedSkills) -> list[SkillChange]: """What the agent left under `/skills` that is not what was put there. Compared against what this run wrote rather than against the database: the @@ -183,7 +178,7 @@ async def collect_changes( guessing wrong silently drops organizational know-how. """ try: - present = await _read_tree(ensure_async(backend)) + present = await _read_tree(workspace) except Exception: # A remote workspace that cannot be listed is a run that proposes # nothing, which is the same as one that changed nothing. @@ -191,7 +186,7 @@ async def collect_changes( return [] changes: list[SkillChange] = [] - for name, files in _by_skill(present).items(): + for name, files in present.items(): if files == { path: content for path, content in state.written.items() if _skill_of(path) == name }: @@ -202,37 +197,39 @@ async def collect_changes( return changes -async def _read_tree(backend: AsyncBackendProtocol) -> dict[str, str]: - """Every file under `/skills`, by path. +async def _read_tree(workspace: Workspace) -> dict[str, dict[str, str]]: + """Every file one level inside a skill's directory under `SKILLS_ROOT`, by skill and path. + + Two levels and no further, because that is the format: a skill is a directory + of files, and `_skill_of` reads nothing deeper. Listed rather than globbed, so a + dot-prefixed resource - a `.gitignore` the agent gave a skill - is in the + proposal like any other file; a proposal is supposed to be everything the + agent left different. Oversized files are dropped rather than truncated: half a script is not a script, and storing it as a proposal would offer a reviewer something that cannot be right. """ - tree: dict[str, str] = {} - # Two patterns: `**/*` does not match a name beginning with a dot, so a skill the - # agent gave a `.gitignore` or a dot-prefixed resource would have that file - # dropped from the proposal without a word - and a proposal is supposed to be - # everything the agent left different. - listed = { - str(entry["path"]): entry - for pattern in (f"{SKILLS_ROOT}/**/*", f"{SKILLS_ROOT}/**/.*") - for entry in await backend.glob_info(pattern) - } - for path, entry in sorted(listed.items()): - if entry.get("is_dir"): - continue - # `or 0`, not a default on `get`: `size` is `int | None` and a listing that - # carries the key with `None` - a host that did not measure the file - would - # otherwise compare `None` to an int and raise inside the ingestion of a - # skill proposal. - if (entry.get("size") or 0) > MAX_PROPOSED_BYTES: - logger.warning("skill_proposal_too_large", extra={"path": path}) + tree: dict[str, dict[str, str]] = {} + try: + skills = await workspace.list_dir(SKILLS_ROOT) + except FileNotFoundError: + return tree + for skill in sorted(skills, key=lambda entry: entry.path): + if not skill.is_dir: continue - # `read_bytes`, not `read`: the toolset's `read` numbers lines for a - # model to quote, and a proposal built from that would store the line - # numbers as part of the skill. - tree[path] = (await backend.read_bytes(path)).decode("utf-8", errors="replace") + for entry in sorted(await workspace.list_dir(skill.path), key=lambda e: e.path): + if entry.is_dir: + continue + # A container's listing does not measure its files - its shell lists + # names - so an unmeasured one is asked, rather than read whole first. + size = entry.size if entry.size is not None else (await workspace.stat(entry.path)).size + if size is not None and size > MAX_PROPOSED_BYTES: + logger.warning("skill_proposal_too_large", extra={"path": entry.path}) + continue + tree.setdefault(skill.name, {})[entry.path] = ( + await workspace.read_bytes(entry.path) + ).decode("utf-8", errors="replace") return tree @@ -248,16 +245,6 @@ def _skill_of(path: str) -> str | None: return parts[0] if len(parts) == 2 else None -def _by_skill(tree: dict[str, str]) -> dict[str, dict[str, str]]: - grouped: dict[str, dict[str, str]] = {} - for path, content in tree.items(): - name = _skill_of(path) - if name is None: - continue - grouped.setdefault(name, {})[path] = content - return grouped - - def _to_change(name: str, files: dict[str, str], skill_id: Any | None) -> SkillChange | None: """One skill's files as a proposal, or `None` if they are not a skill. diff --git a/backend/pyproject.toml b/backend/pyproject.toml index c0df1143a..a711713ea 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -49,15 +49,16 @@ dependencies = [ # middleware is ours: Starlette itself installs without it (#155). "itsdangerous>=2.2.0", "prefect>=3.8.5", - # The floor is `pydantic-ai-harness`'s, not ours - nothing here needs a - # feature of any particular release, and stating it is what keeps a resolver - # from picking a slim the harness cannot run on. Dependabot raises it to + # The floor is ours as well as `pydantic-ai-harness`'s: the sandbox runs on + # `pydantic_ai.workspaces` and passes `workspace=` to a run, both from 2.52, + # and stating it keeps a resolver from picking a slim the harness cannot run + # on. Dependabot raises it to # whatever is current, which is fine as long as the lock resolves to at least # as new: this group's bump once locked 2.30.0 while `main` already held # 2.31.0, and the image installs the lock verbatim (`uv sync --frozen`). - "pydantic-ai-slim[openrouter,anthropic,google,duckduckgo,web-fetch,groq,mistral,cohere,huggingface,xai]>=2.45.0", + "pydantic-ai-slim[openrouter,anthropic,google,duckduckgo,web-fetch,groq,mistral,cohere,huggingface,xai]>=2.52.0", "genai-prices>=0.1.4", - "pydantic-ai-slim[mcp]>=2.45.0", + "pydantic-ai-slim[mcp]>=2.52.0", # The MCP SDK. Uncapped since #1820 moved the one path where our code and # the SDK exchange HTTP objects onto `httpx2`, which 2.0 migrated the whole # SDK to: `PinnedAsyncClient` (`app/core/pinned_http.py`) and the MCP OAuth @@ -81,9 +82,9 @@ dependencies = [ # store, tools and cache-safe tail reminder) and `compaction` (the # context-window handling and its strategies). Uncapped for the same reason # Pydantic AI is: this is where those are maintained, and pinning would - # freeze the detectors and the model registry they read. 0.21.0 is the floor - # all three were written against. - "pydantic-ai-harness>=0.35.0", + # freeze the detectors and the model registry they read. 0.52.0 is the + # floor: the first release on the slim that brought workspaces. + "pydantic-ai-harness>=0.52.0", "websockets>=15.0", # Required, not optional: hybrid retrieval fuses BM25 with the vector search # and a deployment cannot choose otherwise. It is also the only reason @@ -126,12 +127,15 @@ dependencies = [ # own capability list, `load_capability` opens a skill, and only # `read_skill_resource` survives as a tool (#1658). "pydantic-ai-skills>=2.0.0", - # `console` for the toolset, `remote` for the sandboxd client. Deliberately - # not `docker`: this container never touches the socket, so the Docker SDK - # has no business in the image. The service runs beside us as `sandboxd`. - # Floors at 0.2.26 for `FileInfo.modified_at`, which the listings carry to - # the file viewer (#500). - "pydantic-ai-backend[console,remote]>=0.2.29", + # `console` for the tools, `workspaces` for the workspace capabilities, + # `remote` for the Files panel's archive client and `daytona` for Daytona + # connections (the `daytona` package; the old `daytona-sdk` never imported). + # Deliberately not `docker`: this container never touches the socket, so the + # Docker SDK has no business in the image. The service runs beside us as + # `sandboxd`. Floors at 0.2.32 for named sandboxd sessions and Daytona + # sandboxes, which a workspace keyed on its scope opens or attaches by name + # (#2003). + "pydantic-ai-backend[console,remote,workspaces,daytona]>=0.2.32", # Delegation. Floors at 0.2.16 for three fixes this integration depends on: # a suspended child no longer reported to the parent as completed (#64), # `event_stream_handler_factory` on the toolset so a fan-out's events can be @@ -149,8 +153,11 @@ dependencies = [ # stored whole (#819). 0.2.22 puts the subagent's name on the delegation's # bound state, which is the only way a persisted `ask_user` part can say # which delegate asked - `ask_parent` hands the surface the question and - # nothing else (#1042). The floor is 0.2.22. - "subagents-pydantic-ai>=0.2.22", + # nothing else (#1042). 0.2.25 runs a delegate in its parent's workspace, + # which is how a delegate sharing the parent's `sandbox` reaches the same + # files now that the files live in `ctx.workspace` (#2003). The floor is + # 0.2.25. + "subagents-pydantic-ai>=0.2.25", "cryptography>=50.0.1", "aiogram>=3.31.0,<4.0", "slack-sdk>=3.44.1", @@ -237,7 +244,7 @@ dependencies = [ # there is no path where one library checks and the other requests. browser = [ "cdp-use==1.4.5", - "pydantic-ai-slim[typesafe]>=2.45.0", + "pydantic-ai-slim[typesafe]>=2.52.0", ] # Integrated Windows sign-in (SPNEGO), the `KERBEROS_ENABLED` half of #1773. # `gssapi` publishes no Linux wheel: it compiles against the system Kerberos diff --git a/backend/tests/integration/test_workspace_visibility.py b/backend/tests/integration/test_workspace_visibility.py index e5e84141c..8db7ca201 100644 --- a/backend/tests/integration/test_workspace_visibility.py +++ b/backend/tests/integration/test_workspace_visibility.py @@ -235,7 +235,7 @@ async def test_a_stored_file_keeps_its_modified_time_through_the_database(self, member = await _user(db) agent = await _agent(db, organization, member) stored = StateBackend() - stored.write("/report.md", "# findings") + stored.write_bytes("/report.md", b"# findings") workspace = await _workspace(db, organization, agent, files=dict(stored.files)) await db.refresh(workspace) diff --git a/backend/tests/test_agent_chat.py b/backend/tests/test_agent_chat.py index d6efbdfda..b369abb37 100644 --- a/backend/tests/test_agent_chat.py +++ b/backend/tests/test_agent_chat.py @@ -934,6 +934,8 @@ async def test_the_workspace_the_run_opened_is_the_one_the_file_lands_in(self): will look for it.""" from pydantic_ai_backends import StateBackend + from tests.workspaces import document_workspace + attachment = SimpleNamespace( id=uuid.uuid4(), filename="raport.csv", @@ -943,11 +945,13 @@ async def test_the_workspace_the_run_opened_is_the_one_the_file_lands_in(self): file_type="text", parsed_content="month,total", ) - backend = StateBackend() + document = StateBackend() prepared = _prepared() # `parses_documents` is what says the runtime can read a PDF itself; a # stored workspace cannot, and so keeps the extracted text beside the file. - prepared.workspace = SimpleNamespace(backend=backend, briefing=None, parses_documents=False) + prepared.workspace = SimpleNamespace( + workspace=document_workspace(document), briefing=None, parses_documents=False + ) with ( _runner(prepared), @@ -958,7 +962,7 @@ async def test_the_workspace_the_run_opened_is_the_one_the_file_lands_in(self): ): await _run(_db(), attachments=[attachment]) - assert any(path.startswith("/uploads/") for path in backend.files) + assert any(path.startswith("/uploads/") for path in document.files) async def test_a_prompt_already_assembled_as_parts_keeps_its_text(self): """A caller passing the richer shape would otherwise have its diff --git a/backend/tests/test_agent_runner.py b/backend/tests/test_agent_runner.py index 1e6880a7e..e88af7303 100644 --- a/backend/tests/test_agent_runner.py +++ b/backend/tests/test_agent_runner.py @@ -12,14 +12,27 @@ from collections.abc import Awaitable, Callable from datetime import UTC, datetime from decimal import Decimal +from pathlib import Path from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest -from pydantic_ai.exceptions import ModelHTTPError -from pydantic_ai.messages import ModelRequest, ModelResponse, ToolCallPart, ToolReturnPart +from pydantic_ai import Agent +from pydantic_ai.exceptions import ModelHTTPError, UserError +from pydantic_ai.messages import ( + ModelMessage, + ModelRequest, + ModelResponse, + TextPart, + ToolCallPart, + ToolReturnPart, + UserPromptPart, +) +from pydantic_ai.models.function import AgentInfo, FunctionModel from pydantic_ai.tools import DeferredToolRequests from pydantic_ai.usage import RequestUsage +from pydantic_ai.workspaces import LocalWorkspaceBackend, Workspace, WorkspaceRef +from pydantic_ai_backends import StateBackend from pydantic_ai_harness.planning import PlanItem from app.agents.audience import RunAudience @@ -35,6 +48,8 @@ from app.agents.capabilities.compaction import ContextGauge from app.agents.capabilities.guardrails import GuardrailBlocked from app.agents.capabilities.planning import PLANNING_STORE_RESOURCE +from app.agents.capabilities.sandbox import WORKSPACE_RESOURCE +from app.agents.capabilities.sandbox._capability import build_workspace from app.agents.connect_on_use import ConnectionRequest, ConnectOnUse from app.agents.spec import ( AgentSpec, @@ -68,6 +83,7 @@ UnavailablePrefixCollision, ) from app.services.transcript import RecordedToolCall +from tests.workspaces import document_workspace _THE_ASKER = uuid.uuid4() """The person a parked run was answering, told apart from whoever approves it.""" @@ -1133,6 +1149,118 @@ async def test_a_second_call_after_a_decision_records_nothing_new(self): assert channel.requested == [] +def _writes_a_note(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: + """A model that writes `notes.txt` on every turn, then answers.""" + last = messages[-1] + if isinstance(last, ModelRequest) and any(isinstance(p, UserPromptPart) for p in last.parts): + return ModelResponse( + parts=[ToolCallPart("write_file", {"path": "notes.txt", "content": "kept"})] + ) + return ModelResponse(parts=[TextPart("done")]) + + +class TestTheRunWorksInTheWorkspaceItOpened: + """`execute` and `iterate` hand the run the workspace `prepare` opened. + + Left to the capability, a run continuing a history that names another + workspace - the last turn's on a host the agent has since moved off, a + conversation restored from another environment - is refused outright by + Pydantic AI, and a run without one works in the capability's in-memory + fallback while the stored document goes untouched. + """ + + async def _a_history_from_elsewhere( + self, agent: Agent[None, str], root: Path + ) -> list[ModelMessage]: + earlier = await agent.run("first", workspace=Workspace(LocalWorkspaceBackend(root))) + return earlier.all_messages() + + def _prepared_with(self, agent: Agent[None, str], document: StateBackend) -> PreparedRun: + prepared = _prepared() + prepared.built.agent = agent + prepared.built.deps = None + prepared.built.usage_limits = None + prepared.workspace = MagicMock(workspace=document_workspace(document)) + return prepared + + @pytest.mark.anyio + async def test_execute_writes_into_the_opened_workspace(self, tmp_path): + agent = Agent( + FunctionModel(_writes_a_note), capabilities=[build_workspace(include_execute=False)] + ) + history = await self._a_history_from_elsewhere(agent, tmp_path) + document = StateBackend() + + await self._prepared_with(agent, document).execute( + "next", message_history=history, deferred_tool_results=None + ) + + assert document.read_bytes("/notes.txt") == b"kept" + + @pytest.mark.anyio + async def test_iterate_writes_into_the_opened_workspace(self, tmp_path): + agent = Agent( + FunctionModel(_writes_a_note), capabilities=[build_workspace(include_execute=False)] + ) + history = await self._a_history_from_elsewhere(agent, tmp_path) + document = StateBackend() + + async with self._prepared_with(agent, document).iterate( + "next", message_history=history + ) as run: + async for _ in run: + pass + + assert document.read_bytes("/notes.txt") == b"kept" + + @pytest.mark.anyio + @pytest.mark.security + @pytest.mark.parametrize("elsewhere", ["another-organization", "another-connection"]) + async def test_a_history_naming_someone_else_s_workspace_never_reaches_it( + self, tmp_path, elsewhere + ): + """A transcript can carry any ref - copied, restored, or written while the + agent ran on another host - and the run must still work only where the + runner opened it: another organization's document and another + connection's session are left exactly as they were.""" + theirs = StateBackend() + if elsewhere == "another-organization": + foreign: Workspace = document_workspace(theirs, key="dc-another-organization") + else: + + class _OtherHost(LocalWorkspaceBackend): + @property + def ref(self) -> WorkspaceRef: + return WorkspaceRef(provider=f"sandboxd:{uuid.uuid4()}", id="xc-theirs") + + foreign = Workspace(_OtherHost(tmp_path)) + agent = Agent( + FunctionModel(_writes_a_note), capabilities=[build_workspace(include_execute=False)] + ) + history = (await agent.run("first", workspace=foreign)).all_messages() + before = (dict(theirs.files), sorted(p.name for p in tmp_path.iterdir())) + ours = StateBackend() + + await self._prepared_with(agent, ours).execute( + "next", message_history=history, deferred_tool_results=None + ) + + assert ours.read_bytes("/notes.txt") == b"kept" + assert (dict(theirs.files), sorted(p.name for p in tmp_path.iterdir())) == before + + @pytest.mark.anyio + async def test_without_one_the_history_is_refused_rather_than_silently_rehomed(self, tmp_path): + """What the explicit workspace prevents, pinned so a library change that + made it unnecessary - or made it silent - is noticed.""" + agent = Agent( + FunctionModel(_writes_a_note), capabilities=[build_workspace(include_execute=False)] + ) + history = await self._a_history_from_elsewhere(agent, tmp_path) + + with pytest.raises(UserError, match="none of the agent's workspace capabilities"): + await agent.run("next", message_history=history) + + class TestFilesAcrossOneTurn: """What arrived with the message, and what the turn produced. @@ -1158,9 +1286,9 @@ async def test_an_attachment_is_routed_against_the_workspace_the_run_opened(self MagicMock(), uuid.uuid4(), "look at this", attachments=[MagicMock()] ) - # The backend, not the conversation: the router writes the file where the - # agent can read it, and only `prepare` knows whether there is one. - assert router.call_args.args[0] is prepared.workspace.backend + # The workspace, not the conversation: the router writes the file where + # the agent can read it, and only `prepare` knows whether there is one. + assert router.call_args.args[0] is prepared.workspace.workspace assert service._run.await_args.kwargs["user_prompt"] == "a prompt with a reference" @pytest.mark.anyio @@ -3649,13 +3777,13 @@ async def _prepare(spec, skills=()): return prepared, build.call_args @pytest.mark.anyio - async def test_a_workspace_backend_is_handed_to_the_capability(self): + async def test_the_opened_workspace_is_handed_to_the_capabilities(self): spec = AgentSpec(name="Analyst", capabilities=[{"id": "sandbox", "config": {}}]) prepared, built = await self._prepare(spec) assert prepared.workspace is not None - assert built.kwargs["resources"]["workspace_backend"] is prepared.workspace.backend + assert built.kwargs["resources"][WORKSPACE_RESOURCE] is prepared.workspace.workspace @pytest.mark.anyio async def test_an_agent_without_one_is_handed_nothing(self): @@ -3664,7 +3792,7 @@ async def test_an_agent_without_one_is_handed_nothing(self): prepared, built = await self._prepare(AgentSpec(name="Plain")) assert prepared.workspace is None - assert "workspace_backend" not in built.kwargs["resources"] + assert WORKSPACE_RESOURCE not in built.kwargs["resources"] class TestTheModelIsToldWhereTheSkillFilesWent: diff --git a/backend/tests/test_agent_workspace_repo.py b/backend/tests/test_agent_workspace_repo.py index 9936792d9..12e7d56b3 100644 --- a/backend/tests/test_agent_workspace_repo.py +++ b/backend/tests/test_agent_workspace_repo.py @@ -207,7 +207,6 @@ async def test_creating_records_who_the_workspace_belongs_to(self): scope="conversation", scope_key="sc-1", backend="docker", - session_id="sc-1", ) [created] = session.added @@ -215,9 +214,19 @@ async def test_creating_records_who_the_workspace_belongs_to(self): assert created.agent_id == agent_id assert created.conversation_id == conversation_id assert created.owner_ref == "U123" - assert created.session_id == "sc-1" + # Recorded when a session is opened, not when the row is: see `record_session`. + assert created.session_id is None assert created.last_used_at is not None + async def test_recording_a_session_sets_it_and_forgetting_clears_it(self): + session = _RecordingSession() + row = _workspace() + + await agent_workspace_repo.record_session(session, workspace=row, session_id="xc-1") + assert row.session_id == "xc-1" + await agent_workspace_repo.record_session(session, workspace=row, session_id=None) + assert row.session_id is None + async def test_saving_bumps_the_version_so_an_overlap_is_visible(self): """Two turns of one conversation cannot normally run at once; this is how it is *noticed* if they ever do.""" @@ -225,12 +234,13 @@ async def test_saving_bumps_the_version_so_an_overlap_is_visible(self): row = _workspace(version=3) saved = await agent_workspace_repo.save_files( - session, workspace=row, files={"/a.txt": {}}, bytes_total=42 + session, workspace=row, files={"/a.txt": {}}, directories=["/out"], bytes_total=42 ) assert saved.version == 4 assert saved.bytes_total == 42 assert saved.files == {"/a.txt": {}} + assert saved.directories == ["/out"] async def test_touching_records_use_without_changing_the_files(self): session = _RecordingSession() diff --git a/backend/tests/test_artifact_pages.py b/backend/tests/test_artifact_pages.py index 1b51c6f43..12aed24f3 100644 --- a/backend/tests/test_artifact_pages.py +++ b/backend/tests/test_artifact_pages.py @@ -904,6 +904,6 @@ def test_every_library_file_it_names_is_one_the_deployment_serves(self) -> None: def test_the_tool_text_names_the_same_set(self) -> None: from app.agents.capabilities.artifacts._toolset import build_artifacts_toolset - tool = build_artifacts_toolset(workspace_backend=None).tools["publish_artifact"] + tool = build_artifacts_toolset().tools["publish_artifact"] for name in artifacts.ARTIFACT_LIBRARY: assert f"lib/{name}" in (tool.description or "") diff --git a/backend/tests/test_artifacts_capability.py b/backend/tests/test_artifacts_capability.py index 20c44d35f..4519e432d 100644 --- a/backend/tests/test_artifacts_capability.py +++ b/backend/tests/test_artifacts_capability.py @@ -18,6 +18,7 @@ from pydantic_ai.models.test import TestModel from pydantic_ai.tools import RunContext from pydantic_ai.usage import RunUsage +from pydantic_ai.workspaces import WorkspaceError from app.agents.audience import RunAudience from app.agents.capabilities import _registry as registry @@ -29,7 +30,6 @@ build_artifacts_toolset, parse_published_artifact, ) -from app.agents.capabilities.sandbox import WORKSPACE_BACKEND_RESOURCE from app.agents.deps import AgentDeps from app.core.exceptions import AuthorizationError, ConcurrentChangeError, NotFoundError from app.db.models.artifact import ArtifactMediaType @@ -53,13 +53,15 @@ def _deps(**overrides: Any) -> AgentDeps: return AgentDeps(**values) -def _ctx(deps: AgentDeps, *, retry: int = 0) -> RunContext[AgentDeps]: - return RunContext(deps=deps, model=TestModel(), usage=RunUsage(), retry=retry, max_retries=1) +def _ctx(deps: AgentDeps, *, retry: int = 0, workspace: Any = None) -> RunContext[AgentDeps]: + ctx = RunContext(deps=deps, model=TestModel(), usage=RunUsage(), retry=retry, max_retries=1) + if workspace is not None: + ctx.workspace = workspace + return ctx -def _tool(workspace: Any = None) -> Any: - toolset = build_artifacts_toolset(workspace_backend=workspace) - return toolset.tools["publish_artifact"].function +def _tool() -> Any: + return build_artifacts_toolset().tools["publish_artifact"].function def _published(**overrides: Any) -> PublishedArtifact: @@ -80,12 +82,13 @@ def _published(**overrides: Any) -> PublishedArtifact: def _workspace(data: bytes | Exception) -> MagicMock: - backend = MagicMock() + """The run's workspace, holding `data` at every path - or raising it.""" + workspace = MagicMock(attached=True) if isinstance(data, Exception): - backend.read_bytes = AsyncMock(side_effect=data) + workspace.read_bytes = AsyncMock(side_effect=data) else: - backend.read_bytes = AsyncMock(return_value=data) - return backend + workspace.read_bytes = AsyncMock(return_value=data) + return workspace class TestPublishing: @@ -125,7 +128,9 @@ async def test_a_name_somebody_else_s_page_holds_is_a_refusal_not_a_retry(self) async def test_a_workspace_file_is_published_as_the_file_it_is(self) -> None: workspace = _workspace(b"# Report\n") with patch(PUBLISH, new=AsyncMock(return_value=_published())) as publish: - await _tool(workspace)(_ctx(_deps()), name="r", title="R", path="out/report.md") + await _tool()( + _ctx(_deps(), workspace=workspace), name="r", title="R", path="out/report.md" + ) workspace.read_bytes.assert_awaited_once_with("out/report.md") assert publish.await_args.kwargs["media_type"] is ArtifactMediaType.MARKDOWN @@ -169,13 +174,28 @@ async def test_a_run_with_nothing_to_publish_under_is_told_so(self, missing: str async def test_a_refused_read_is_a_result_not_a_retry(self) -> None: workspace = _workspace(PermissionError("secrets/ is not readable")) with patch(PUBLISH, new=AsyncMock()) as publish: - result = await _tool(workspace)( - _ctx(_deps()), name="r", title="R", path="secrets/x.html" + result = await _tool()( + _ctx(_deps(), workspace=workspace), name="r", title="R", path="secrets/x.html" ) publish.assert_not_awaited() assert result == "Reading 'secrets/x.html' was refused: secrets/ is not readable" +class TestAFailedRead: + async def test_a_container_that_cannot_produce_the_file_is_a_result_not_a_crash(self) -> None: + """The shell under a container's file read can fail - the session gone, + its output cut short. The model is told and can publish inline instead.""" + with patch(PUBLISH, new=AsyncMock()) as publish: + result = await _tool()( + _ctx(_deps(), workspace=_workspace(WorkspaceError("the sandbox is gone"))), + name="r", + title="R", + path="report.html", + ) + publish.assert_not_awaited() + assert result == "Reading 'report.html' failed: the sandbox is gone" + + class TestSteering: @pytest.mark.parametrize( ("arguments", "says"), @@ -196,16 +216,19 @@ async def test_a_call_the_model_can_fix_is_steered( patch(PUBLISH, new=AsyncMock()) as publish, pytest.raises(ModelRetry, match=says), ): - await _tool(_workspace(b"

x

"))(_ctx(_deps()), **call) + await _tool()(_ctx(_deps(), workspace=_workspace(b"

x

")), **call) publish.assert_not_awaited() async def test_a_path_with_no_workspace_asks_for_inline_content(self) -> None: with pytest.raises(ModelRetry, match="no workspace"): await _tool()(_ctx(_deps()), name="r", title="R", path="report.html") - async def test_a_missing_file_says_to_look(self) -> None: + @pytest.mark.parametrize("stored", [b"", FileNotFoundError("nope.html")]) + async def test_a_missing_or_empty_file_says_to_look(self, stored: bytes | Exception) -> None: with pytest.raises(ModelRetry, match="Check the path"): - await _tool(_workspace(b""))(_ctx(_deps()), name="r", title="R", path="nope.html") + await _tool()( + _ctx(_deps(), workspace=_workspace(stored)), name="r", title="R", path="nope.html" + ) async def test_on_the_last_attempt_the_steer_is_returned_rather_than_ending_the_run( self, @@ -224,13 +247,8 @@ class TestRegistration: def test_the_capability_reads_the_run_s_workspace_and_is_not_side_effecting(self) -> None: definition = registry.get("artifacts") assert definition.side_effecting is False - workspace = object() - (built,) = registry.build( - [registry.CapabilityBinding(capability_id="artifacts")], - resources={WORKSPACE_BACKEND_RESOURCE: workspace}, - ) + (built,) = registry.build([registry.CapabilityBinding(capability_id="artifacts")]) assert isinstance(built, Artifacts) - assert built.workspace_backend is workspace toolset = built.get_toolset() assert toolset is built.get_toolset() assert set(toolset.tools) == {"publish_artifact", "read_artifact"} @@ -251,8 +269,8 @@ def _source(text: str = "

Old

body

", **overrides: Any) -> Artifact return ArtifactSource(**values) -def _reader(workspace: Any = None) -> Any: - return build_artifacts_toolset(workspace_backend=workspace).tools["read_artifact"].function +def _reader() -> Any: + return build_artifacts_toolset().tools["read_artifact"].function class TestEditing: diff --git a/backend/tests/test_attachments.py b/backend/tests/test_attachments.py index d16413938..3157438b7 100644 --- a/backend/tests/test_attachments.py +++ b/backend/tests/test_attachments.py @@ -15,11 +15,11 @@ import pytest from pydantic_ai.messages import BinaryContent -from pydantic_ai_backends import StateBackend from app.agents.capabilities.sandbox._capped import CappedStateBackend from app.services import attachments as attachments_module from app.services.attachments import AttachmentRouter, safe_name, workspace_path +from tests.workspaces import ShellFailing, document_workspace pytestmark = pytest.mark.anyio @@ -49,8 +49,9 @@ def storage(monkeypatch): return loaded -def _workspace(max_bytes: int = 1024 * 1024) -> CappedStateBackend: - return CappedStateBackend(StateBackend(), max_bytes=max_bytes) +def _document(max_bytes: int = 1024 * 1024) -> CappedStateBackend: + """A stored workspace's document, which the tests read back to see what landed.""" + return CappedStateBackend(max_bytes=max_bytes) class TestWithoutAWorkspace: @@ -103,19 +104,23 @@ async def test_no_attachments_is_the_message_unchanged(self): class TestWithAWorkspace: async def test_the_file_is_written_and_referenced_rather_than_pasted(self, storage): """The whole point: a large file stops costing its weight every turn.""" - backend = _workspace() + document = _document() chat_file = _file(parsed_content="month,total\n" + "row\n" * 500) - prompt = await AttachmentRouter(backend).build_prompt("summarise", [chat_file]) + prompt = await AttachmentRouter(document_workspace(document)).build_prompt( + "summarise", [chat_file] + ) assert workspace_path(chat_file) in prompt - assert backend.exists(workspace_path(chat_file)) + assert document.exists(workspace_path(chat_file)) assert prompt.count("row") < 100 async def test_the_reference_carries_a_head_the_model_can_judge_from(self, storage): - backend = _workspace() + document = _document() - prompt = await AttachmentRouter(backend).build_prompt("summarise", [_file()]) + prompt = await AttachmentRouter(document_workspace(document)).build_prompt( + "summarise", [_file()] + ) assert "month,total" in prompt assert "First 20 lines" in prompt @@ -123,23 +128,27 @@ async def test_the_reference_carries_a_head_the_model_can_judge_from(self, stora async def test_an_image_is_both_seen_and_written(self, storage): """A path is not a substitute for looking, and looking is not a substitute for being able to resize it.""" - backend = _workspace() + document = _document() chat_file = _file(file_type="image", mime_type="image/png", parsed_content=None) - prompt = await AttachmentRouter(backend).build_prompt("crop this", [chat_file]) + prompt = await AttachmentRouter(document_workspace(document)).build_prompt( + "crop this", [chat_file] + ) assert isinstance(prompt, list) assert isinstance(prompt[1], BinaryContent) - assert backend.exists(workspace_path(chat_file)) + assert document.exists(workspace_path(chat_file)) async def test_a_large_image_is_written_and_not_also_inlined(self, storage, monkeypatch): from app.core import config as config_module monkeypatch.setattr(config_module.settings, "SANDBOX_INLINE_IMAGE_MAX_BYTES", 100) - backend = _workspace() + document = _document() chat_file = _file(file_type="image", mime_type="image/png", parsed_content=None, size=5000) - prompt = await AttachmentRouter(backend).build_prompt("shrink it", [chat_file]) + prompt = await AttachmentRouter(document_workspace(document)).build_prompt( + "shrink it", [chat_file] + ) assert isinstance(prompt, str) assert workspace_path(chat_file) in prompt @@ -158,11 +167,13 @@ async def test_a_turn_stops_inlining_images_once_its_budget_is_spent( storage.return_value = b"\x89PNG" + b"\x00" * 4996 monkeypatch.setattr(config_module.settings, "CHAT_TURN_INLINE_MAX_BYTES", 6000) monkeypatch.setattr(config_module.settings, "SANDBOX_INLINE_IMAGE_MAX_BYTES", 10_000) - backend = _workspace() + document = _document() first = _file(file_type="image", mime_type="image/png", parsed_content=None, size=5000) second = _file(file_type="image", mime_type="image/png", parsed_content=None, size=5000) - prompt = await AttachmentRouter(backend).build_prompt("compare", [first, second]) + prompt = await AttachmentRouter(document_workspace(document)).build_prompt( + "compare", [first, second] + ) assert isinstance(prompt, list) assert sum(isinstance(part, BinaryContent) for part in prompt) == 1 @@ -174,10 +185,10 @@ async def test_an_image_larger_than_what_the_turn_has_left_is_named_not_shown( from app.core import config as config_module monkeypatch.setattr(config_module.settings, "SANDBOX_INLINE_IMAGE_MAX_BYTES", 10_000) - backend = _workspace() + document = _document() chat_file = _file(file_type="image", mime_type="image/png", parsed_content=None, size=5000) - router = AttachmentRouter(backend) + router = AttachmentRouter(document_workspace(document)) plan = await router.route(chat_file, inline_budget=100) assert plan.inline == [] @@ -194,7 +205,7 @@ async def test_an_image_larger_than_what_the_turn_has_left_is_named_not_shown( async def test_a_pdf_keeps_its_bytes_and_gains_its_text(self, storage): """A shell has no tool for a PDF; the extracted text is the usable half.""" - backend = _workspace() + document = _document() chat_file = _file( filename="contract.pdf", file_type="pdf", @@ -202,15 +213,15 @@ async def test_a_pdf_keeps_its_bytes_and_gains_its_text(self, storage): parsed_content="Clause 1. The parties agree.", ) - await AttachmentRouter(backend).build_prompt("summarise", [chat_file]) + await AttachmentRouter(document_workspace(document)).build_prompt("summarise", [chat_file]) - assert backend.exists(workspace_path(chat_file)) + assert document.exists(workspace_path(chat_file)) async def test_the_same_file_on_a_later_turn_is_not_written_again(self, storage): """Otherwise an upload costs one write per turn for the whole chat.""" - backend = _workspace() + document = _document() chat_file = _file() - router = AttachmentRouter(backend) + router = AttachmentRouter(document_workspace(document)) await router.build_prompt("first", [chat_file]) await router.build_prompt("second", [chat_file]) @@ -218,25 +229,63 @@ async def test_the_same_file_on_a_later_turn_is_not_written_again(self, storage) assert storage.await_count == 1 async def test_two_files_of_the_same_name_do_not_overwrite_each_other(self, storage): - backend = _workspace() + document = _document() first, second = _file(), _file() - await AttachmentRouter(backend).build_prompt("compare", [first, second]) + await AttachmentRouter(document_workspace(document)).build_prompt( + "compare", [first, second] + ) assert workspace_path(first) != workspace_path(second) - assert backend.exists(workspace_path(first)) - assert backend.exists(workspace_path(second)) + assert document.exists(workspace_path(first)) + assert document.exists(workspace_path(second)) async def test_a_full_workspace_still_names_and_samples_the_file(self, storage): """The file is still worth mentioning; it just cannot be stored.""" - backend = _workspace(max_bytes=1) + document = _document(max_bytes=1) chat_file = _file() - prompt = await AttachmentRouter(backend).build_prompt("summarise", [chat_file]) + prompt = await AttachmentRouter(document_workspace(document)).build_prompt( + "summarise", [chat_file] + ) assert "month,total" in prompt assert chat_file.filename in prompt - assert not backend.exists(workspace_path(chat_file)) + assert not document.exists(workspace_path(chat_file)) + + async def test_a_container_that_cannot_take_the_file_still_names_it(self, storage): + """A container moves the bytes through its shell, which fails with + `WorkspaceError` - a full disk, an image missing `base64` - rather than the + `OSError` of a full document. Either way the turn goes on without the file + in the workspace, rather than failing before the model is asked.""" + chat_file = _file() + + prompt = await AttachmentRouter(ShellFailing(document_workspace())).build_prompt( + "summarise", [chat_file] + ) + + assert isinstance(prompt, str) + assert chat_file.filename in prompt + assert "could not be written" in prompt + + async def test_a_parse_a_container_cannot_take_is_not_named(self, storage): + """The sibling write fails the same way, on its own.""" + + class _OnlyTheOriginal(ShellFailing): + async def write_bytes(self, path: str, data: bytes) -> None: + if path.endswith(".txt"): + await super().write_bytes(path, data) + await self.wrapped.write_bytes(path, data) + + chat_file = _file(filename="report.pdf", file_type="pdf", parsed_content="page one") + document = _document() + + prompt = await AttachmentRouter( + _OnlyTheOriginal(document_workspace(document)) + ).build_prompt("read it", [chat_file]) + + assert document.exists(workspace_path(chat_file)) + assert "beside it at" not in prompt async def test_a_file_too_large_to_store_is_not_pasted_whole(self, storage): """The degradation used to run backwards. @@ -250,7 +299,9 @@ async def test_a_file_too_large_to_store_is_not_pasted_whole(self, storage): body = "\n".join(f"row-{n},{n}" for n in range(500)) chat_file = _file(parsed_content=body) - prompt = await AttachmentRouter(_workspace(max_bytes=1)).build_prompt("go", [chat_file]) + prompt = await AttachmentRouter(document_workspace(_document(max_bytes=1))).build_prompt( + "go", [chat_file] + ) assert isinstance(prompt, str) assert "row-0" in prompt @@ -263,7 +314,9 @@ async def test_an_unparsed_file_too_large_to_store_is_still_named(self, storage) and could not be kept - which still beats the model never hearing of it.""" chat_file = _file(filename="dump.zip", file_type="other", parsed_content=None) - prompt = await AttachmentRouter(_workspace(max_bytes=1)).build_prompt("go", [chat_file]) + prompt = await AttachmentRouter(document_workspace(_document(max_bytes=1))).build_prompt( + "go", [chat_file] + ) assert isinstance(prompt, str) assert "dump.zip" in prompt @@ -274,7 +327,9 @@ async def test_a_file_that_was_not_stored_is_given_no_path_to_open(self, storage find out the file is not there.""" chat_file = _file() - prompt = await AttachmentRouter(_workspace(max_bytes=1)).build_prompt("go", [chat_file]) + prompt = await AttachmentRouter(document_workspace(_document(max_bytes=1))).build_prompt( + "go", [chat_file] + ) assert isinstance(prompt, str) assert workspace_path(chat_file) not in prompt @@ -314,7 +369,9 @@ async def test_an_image_too_large_to_store_is_still_shown_to_the_model(self, sto substitute for looking at the picture, and the inline ceiling is its own.""" chat_file = _file(file_type="image", mime_type="image/png", filename="chart.png") - prompt = await AttachmentRouter(_workspace(max_bytes=1)).build_prompt("look", [chat_file]) + prompt = await AttachmentRouter(document_workspace(_document(max_bytes=1))).build_prompt( + "look", [chat_file] + ) assert isinstance(prompt, list) assert any(isinstance(part, BinaryContent) for part in prompt) @@ -329,7 +386,7 @@ async def test_a_file_the_store_cannot_load_is_named_rather_than_fatal(self, mon lambda: SimpleNamespace(load=AsyncMock(side_effect=OSError("gone"))), ) - prompt = await AttachmentRouter(_workspace()).build_prompt( + prompt = await AttachmentRouter(document_workspace(_document())).build_prompt( "summarise", [_file(filename="raport.csv")] ) @@ -369,10 +426,12 @@ def test_a_generated_image_lands_there_too(self): async def test_the_model_is_told_the_path_the_file_is_actually_at(self, storage): # The reference is the only thing that tells a model where to look, so a # path in it that a shell cannot reach is worse than no path at all. - backend = _workspace() + document = _document() chat_file = _file(filename="report.pdf", file_type="pdf", parsed_content="a b c") - prompt = await AttachmentRouter(backend).build_prompt("read it", [chat_file]) + prompt = await AttachmentRouter(document_workspace(document)).build_prompt( + "read it", [chat_file] + ) assert workspace_path(chat_file) in prompt assert "/uploads/" not in prompt @@ -390,7 +449,9 @@ class TestWhatTheModelIsToldAboutAPath: async def test_the_path_gets_its_own_clause(self, storage): chat_file = _file(filename="report.csv", file_type="spreadsheet", parsed_content="a,b\n1,2") - prompt = await AttachmentRouter(_workspace()).build_prompt("read it", [chat_file]) + prompt = await AttachmentRouter(document_workspace(_document())).build_prompt( + "read it", [chat_file] + ) assert f"in your workspace at {workspace_path(chat_file)}" in prompt @@ -400,14 +461,14 @@ async def test_a_runtime_that_can_read_the_file_gets_no_copy_beside_it(self, sto markdown, OCR included - that is a second copy of the file's contents on disk to save the agent a tool call it should be making.""" chat_file = _file(filename="report.pdf", file_type="pdf", parsed_content="page one") - backend = _workspace() + document = _document() - prompt = await AttachmentRouter(backend, can_parse=True).build_prompt( + prompt = await AttachmentRouter(document_workspace(document), can_parse=True).build_prompt( "read it", [chat_file] ) assert f"in your workspace at {workspace_path(chat_file)}" in prompt - assert not backend.exists(f"{workspace_path(chat_file)}.txt") + assert not document.exists(f"{workspace_path(chat_file)}.txt") assert "beside it at" not in prompt async def test_a_workspace_that_cannot_read_it_keeps_the_extracted_text(self, storage): @@ -416,11 +477,13 @@ async def test_a_workspace_that_cannot_read_it_keeps_the_extracted_text(self, st prompt and an unreadable binary on disk. A Daytona sandbox and somebody's own runtime are the same case - their image is not ours.""" chat_file = _file(filename="report.pdf", file_type="pdf", parsed_content="page one") - backend = _workspace() + document = _document() - prompt = await AttachmentRouter(backend).build_prompt("read it", [chat_file]) + prompt = await AttachmentRouter(document_workspace(document)).build_prompt( + "read it", [chat_file] + ) - assert backend.exists(f"{workspace_path(chat_file)}.txt") + assert document.exists(f"{workspace_path(chat_file)}.txt") assert f"beside it at {workspace_path(chat_file)}.txt" in prompt async def test_a_sibling_the_workspace_refused_is_not_named(self, storage): @@ -433,12 +496,14 @@ async def test_a_sibling_the_workspace_refused_is_not_named(self, storage): """ chat_file = _file(filename="report.pdf", file_type="pdf", parsed_content="page one" * 400) # Room for the 72-byte original and nothing like the 3,200-byte parse. - backend = _workspace(max_bytes=900) + document = _document(max_bytes=900) - prompt = await AttachmentRouter(backend).build_prompt("read it", [chat_file]) + prompt = await AttachmentRouter(document_workspace(document)).build_prompt( + "read it", [chat_file] + ) assert f"in your workspace at {workspace_path(chat_file)}" in prompt - assert not backend.exists(f"{workspace_path(chat_file)}.txt") + assert not document.exists(f"{workspace_path(chat_file)}.txt") assert "beside it at" not in prompt async def test_a_sibling_from_an_earlier_turn_is_still_named(self, storage): @@ -446,10 +511,12 @@ async def test_a_sibling_from_an_earlier_turn_is_still_named(self, storage): reference has to come from what the workspace holds rather than from what this turn did.""" chat_file = _file(filename="report.pdf", file_type="pdf", parsed_content="page one") - backend = _workspace() + document = _document() - await AttachmentRouter(backend).build_prompt("read it", [chat_file]) - prompt = await AttachmentRouter(backend).build_prompt("again", [chat_file]) + await AttachmentRouter(document_workspace(document)).build_prompt("read it", [chat_file]) + prompt = await AttachmentRouter(document_workspace(document)).build_prompt( + "again", [chat_file] + ) assert f"beside it at {workspace_path(chat_file)}.txt" in prompt @@ -457,18 +524,20 @@ async def test_a_spreadsheet_is_the_case_that_makes_it_matter(self, storage): """`.xlsx` in a workspace with no shell is a zip of XML that `read_file` returns as mojibake.""" chat_file = _file(filename="q3.xlsx", file_type="spreadsheet", parsed_content="a,b") - backend = _workspace() + document = _document() - await AttachmentRouter(backend).build_prompt("read it", [chat_file]) + await AttachmentRouter(document_workspace(document)).build_prompt("read it", [chat_file]) - assert backend.exists(f"{workspace_path(chat_file)}.txt") + assert document.exists(f"{workspace_path(chat_file)}.txt") async def test_an_image_is_told_where_it_is_as_well_as_shown(self, storage): # Both, and the path is the half that lets it be resized, converted or read # by something the model writes. chat_file = _file(filename="chart.png", file_type="image") - prompt = await AttachmentRouter(_workspace()).build_prompt("look", [chat_file]) + prompt = await AttachmentRouter(document_workspace(_document())).build_prompt( + "look", [chat_file] + ) assert isinstance(prompt, list) assert f"in your workspace at {workspace_path(chat_file)}" in prompt[0] @@ -494,19 +563,21 @@ def test_a_name_of_nothing_usable_still_produces_one(self): class TestTheReferenceReadsWell: async def test_megabytes_are_shown_as_megabytes(self, storage): - prompt = await AttachmentRouter(_workspace()).build_prompt( + prompt = await AttachmentRouter(document_workspace(_document())).build_prompt( "x", [_file(size=3 * 1024 * 1024)] ) assert "3.0 MB" in prompt async def test_a_small_file_is_shown_in_kilobytes(self, storage): - prompt = await AttachmentRouter(_workspace()).build_prompt("x", [_file(size=10)]) + prompt = await AttachmentRouter(document_workspace(_document())).build_prompt( + "x", [_file(size=10)] + ) assert "1 KB" in prompt async def test_a_file_with_no_text_is_referenced_without_a_head(self, storage): - prompt = await AttachmentRouter(_workspace()).build_prompt( + prompt = await AttachmentRouter(document_workspace(_document())).build_prompt( "x", [_file(file_type="binary", parsed_content=None)] ) @@ -514,7 +585,7 @@ async def test_a_file_with_no_text_is_referenced_without_a_head(self, storage): assert "raport.csv" in prompt async def test_one_enormous_line_is_still_bounded(self, storage): - prompt = await AttachmentRouter(_workspace()).build_prompt( + prompt = await AttachmentRouter(document_workspace(_document())).build_prompt( "x", [_file(parsed_content="a" * 50_000)] ) @@ -525,9 +596,9 @@ class TestASecondTurn: async def test_an_image_already_in_the_workspace_is_still_shown(self, storage): """The write is skipped on the second turn; the model still has to see the picture, so the bytes are fetched for the inline half alone.""" - backend = _workspace() + document = _document() chat_file = _file(file_type="image", mime_type="image/png", parsed_content=None) - router = AttachmentRouter(backend) + router = AttachmentRouter(document_workspace(document)) await router.build_prompt("what is this?", [chat_file]) prompt = await router.build_prompt("and now?", [chat_file]) @@ -605,20 +676,24 @@ async def test_a_tiff_with_no_usable_page_is_named_not_dropped(self, storage, mo async def test_a_workspace_tiff_is_referenced_and_its_pages_shown(self, storage, monkeypatch): self._stub(monkeypatch, {"images": [b"P1", b"P2"], "total": 4, "omitted": True}) - backend = _workspace() + document = _document() chat_file = self._tiff() - prompt = await AttachmentRouter(backend).build_prompt("read it", [chat_file]) + prompt = await AttachmentRouter(document_workspace(document)).build_prompt( + "read it", [chat_file] + ) assert isinstance(prompt, list) assert f"in your workspace at {workspace_path(chat_file)}" in prompt[0] assert "Showing 2 of 4 pages" in prompt[0] - assert backend.exists(workspace_path(chat_file)) + assert document.exists(workspace_path(chat_file)) async def test_a_workspace_tiff_that_fits_needs_no_note(self, storage, monkeypatch): self._stub(monkeypatch, {"images": [b"P1"], "total": 1, "omitted": False}) - prompt = await AttachmentRouter(_workspace()).build_prompt("look", [self._tiff()]) + prompt = await AttachmentRouter(document_workspace(_document())).build_prompt( + "look", [self._tiff()] + ) assert isinstance(prompt, list) assert "Showing" not in prompt[0] @@ -669,33 +744,35 @@ async def test_a_truncated_reference_is_dropped_whole_not_sliced(self, storage, class TestTheSiblingForTheNewFormats: async def test_a_presentation_gets_its_text_beside_it(self, storage): chat_file = _file(filename="deck.pptx", file_type="presentation", parsed_content="Slide 1") - backend = _workspace() + document = _document() - await AttachmentRouter(backend).build_prompt("read it", [chat_file]) + await AttachmentRouter(document_workspace(document)).build_prompt("read it", [chat_file]) - assert backend.exists(f"{workspace_path(chat_file)}.txt") + assert document.exists(f"{workspace_path(chat_file)}.txt") async def test_an_email_gets_a_sibling_even_on_a_lit_runtime(self, storage): """`lit` reads office documents but not `.msg`, so the extracted text is the only readable form and must be written even where the sibling is otherwise skipped (#1591, §5 #7).""" chat_file = _file(filename="thread.msg", file_type="email", parsed_content="From: a@b") - backend = _workspace() + document = _document() - prompt = await AttachmentRouter(backend, can_parse=True).build_prompt( + prompt = await AttachmentRouter(document_workspace(document), can_parse=True).build_prompt( "read it", [chat_file] ) - assert backend.exists(f"{workspace_path(chat_file)}.txt") + assert document.exists(f"{workspace_path(chat_file)}.txt") assert "beside it at" in prompt async def test_a_document_is_skipped_beside_it_on_a_lit_runtime(self, storage): chat_file = _file(filename="memo.doc", file_type="document", parsed_content="Dear all") - backend = _workspace() + document = _document() - await AttachmentRouter(backend, can_parse=True).build_prompt("read it", [chat_file]) + await AttachmentRouter(document_workspace(document), can_parse=True).build_prompt( + "read it", [chat_file] + ) - assert not backend.exists(f"{workspace_path(chat_file)}.txt") + assert not document.exists(f"{workspace_path(chat_file)}.txt") class TestLoadingTheRows: @@ -744,7 +821,9 @@ class TestWhatTheModelIsToldAboutAFailedWrite: async def test_it_does_not_blame_the_size(self, storage): chat_file = _file() - prompt = await AttachmentRouter(_workspace(max_bytes=1)).build_prompt("go", [chat_file]) + prompt = await AttachmentRouter(document_workspace(_document(max_bytes=1))).build_prompt( + "go", [chat_file] + ) assert isinstance(prompt, str) assert "too large" not in prompt @@ -753,7 +832,9 @@ async def test_it_does_not_blame_the_size(self, storage): async def test_it_tells_the_model_not_to_invent_one(self, storage): """The model fills a gap where a cause is missing - it offered "because of its size" unprompted once the sentence stopped saying so.""" - prompt = await AttachmentRouter(_workspace(max_bytes=1)).build_prompt("go", [_file()]) + prompt = await AttachmentRouter(document_workspace(_document(max_bytes=1))).build_prompt( + "go", [_file()] + ) assert isinstance(prompt, str) assert "Do not guess at why" in prompt @@ -772,7 +853,9 @@ class TestWhenTheWorkspaceWillNotTakeAWrite: async def test_the_turn_is_told_the_workspace_is_unavailable(self, storage): chat_file = _file() - prompt = await AttachmentRouter(_workspace(max_bytes=1)).build_prompt("go", [chat_file]) + prompt = await AttachmentRouter(document_workspace(_document(max_bytes=1))).build_prompt( + "go", [chat_file] + ) assert isinstance(prompt, str) assert "would not accept a write this turn" in prompt @@ -783,13 +866,17 @@ async def test_it_is_said_once_however_many_files_were_refused(self, storage): broken machine, not three.""" files = [_file(), _file(filename="second.pdf"), _file(filename="third.pdf")] - prompt = await AttachmentRouter(_workspace(max_bytes=1)).build_prompt("go", files) + prompt = await AttachmentRouter(document_workspace(_document(max_bytes=1))).build_prompt( + "go", files + ) assert isinstance(prompt, str) assert prompt.count("would not accept a write this turn") == 1 async def test_nothing_is_said_when_every_write_landed(self, storage): - prompt = await AttachmentRouter(_workspace()).build_prompt("go", [_file()]) + prompt = await AttachmentRouter(document_workspace(_document())).build_prompt( + "go", [_file()] + ) assert isinstance(prompt, str) assert "would not accept a write" not in prompt diff --git a/backend/tests/test_capability_registry.py b/backend/tests/test_capability_registry.py index d8703e5db..12aeedf86 100644 --- a/backend/tests/test_capability_registry.py +++ b/backend/tests/test_capability_registry.py @@ -16,9 +16,7 @@ import pytest from pydantic import ValidationError from pydantic_ai import Agent as PydanticAgent -from pydantic_ai._run_context import RunContext from pydantic_ai.models.test import TestModel -from pydantic_ai.usage import RunUsage from app.agents.capabilities import ( REGISTRY, @@ -48,7 +46,7 @@ ) from app.core.exceptions import BadRequestError from app.core.secret_kinds import ApiKeySecret, SecretKind, StorableSecret -from app.services.capability_contracts import real_tool_definition +from app.services.capability_contracts import probe_context, real_tool_definition @pytest.fixture(autouse=True) @@ -56,11 +54,6 @@ def _builtins_loaded(): load_builtins() -def _run_context() -> RunContext[None]: - """The least a wrapped toolset needs before it will list its tools.""" - return RunContext(deps=None, model=TestModel(), usage=RunUsage()) - - class TestSelfLoading: """The registry must not depend on the caller having warmed it up. @@ -351,7 +344,7 @@ async def _offered(built: Any) -> frozenset[str]: toolset = built.get_toolset() if toolset is None: return frozenset() - return frozenset(await toolset.get_tools(_run_context())) + return frozenset(await toolset.get_tools(probe_context(built))) @staticmethod async def _offered_descriptions(built: Any) -> dict[str, str]: @@ -367,7 +360,7 @@ async def _offered_descriptions(built: Any) -> dict[str, str]: toolset = built.get_toolset() if toolset is None: return {} - offered = await toolset.get_tools(_run_context()) + offered = await toolset.get_tools(probe_context(built)) return { name: getattr(real_tool_definition(tool), "description", "") or "" for name, tool in offered.items() diff --git a/backend/tests/test_capability_tools.py b/backend/tests/test_capability_tools.py index 527e0546c..61714d866 100644 --- a/backend/tests/test_capability_tools.py +++ b/backend/tests/test_capability_tools.py @@ -26,7 +26,8 @@ from pydantic_ai.models.test import TestModel from pydantic_ai.settings import ModelSettings from pydantic_ai.usage import RequestUsage, RunUsage -from pydantic_ai_backends import StateBackend +from pydantic_ai.workspaces import Workspace +from pydantic_ai_backends import ConsoleCapability, StateBackend from pydantic_ai_backends.permissions import PermissionChecker from app.agents.capabilities.budget import ( @@ -56,6 +57,7 @@ from app.agents.deps import AgentDeps from app.services.rag.models import ParentContextMode from app.services.rag.query_analysis import QueryExpansionFailed +from tests.workspaces import document_workspace def _tool_ctx(deps: Any = None, *, retry: int = 0, max_retries: int = 1) -> RunContext[Any]: @@ -725,19 +727,26 @@ class TestTheWorkspaceRefusesAnOffLimitsPath: pytestmark = pytest.mark.anyio @staticmethod - def _workspace() -> StateBackend: - backend = StateBackend() - backend.write("/notes.txt", "ordinary work") - backend.write("/chart.png", "not really a png") - backend.write("/.env", "OPENAI_API_KEY=sk-live-secret") - backend.write("/sub/.env", "NESTED=sk-live-secret") - backend.write("/credentials.txt", "PASSWORD=hunter2") - backend.write("/etc/passwd", "root:x:0:0") - return backend + def _workspace() -> Workspace: + document = StateBackend() + for path, text in { + "/notes.txt": "ordinary work", + "/chart.png": "not really a png", + "/.env": "OPENAI_API_KEY=sk-live-secret", + "/sub/.env": "NESTED=sk-live-secret", + "/credentials.txt": "PASSWORD=hunter2", + "/etc/passwd": "root:x:0:0", + }.items(): + document.write_bytes(path, text.encode()) + return document_workspace(document) async def _call(self, name: str, **kwargs: Any) -> Any: - capability = build_workspace(backend=self._workspace(), include_execute=False) - result = capability._toolset.tools[name].function(MagicMock(), **kwargs) + capability = build_workspace(include_execute=False) + console = next(c for c in capability.capabilities if isinstance(c, ConsoleCapability)) + ctx = RunContext( + deps=None, model=TestModel(), usage=RunUsage(), workspace=self._workspace() + ) + result = console._toolset.tools[name].function(ctx, **kwargs) return await result if asyncio.iscoroutine(result) else result @pytest.mark.parametrize("path", ["/.env", "/sub/.env", "/credentials.txt", "/etc/passwd"]) diff --git a/backend/tests/test_channel_attachments.py b/backend/tests/test_channel_attachments.py index 45b9be500..36c41723c 100644 --- a/backend/tests/test_channel_attachments.py +++ b/backend/tests/test_channel_attachments.py @@ -14,12 +14,19 @@ import contextlib import uuid -from collections.abc import Iterator +from collections.abc import Iterator, Sequence from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest +from pydantic_ai.workspaces import ( + CommandResult, + FileEntry, + LocalWorkspaceBackend, + Workspace, + WrapperWorkspace, +) from pydantic_ai_backends import StateBackend from app.core.exceptions import BadRequestError, NotFoundError @@ -37,6 +44,7 @@ from app.services.channels.router import ChannelMessageRouter from app.services.file_storage import LocalFileStorage from app.services.file_upload import FileUploadService +from tests.workspaces import document_workspace pytestmark = pytest.mark.anyio @@ -438,22 +446,30 @@ async def test_a_message_with_no_files_asks_nothing_of_the_platform(self): adapter.download_attachment.assert_not_called() +def _write(document: StateBackend, path: str, content: str | bytes) -> None: + document.write_bytes(path, content.encode() if isinstance(content, str) else content) + + +class _ListingFails(WrapperWorkspace): + async def list_dir(self, path: str) -> Sequence[FileEntry]: + raise RuntimeError("the service is down") + + class TestChoosingWhatToSendBack: """What a reply carries, and what it declines to. - Both functions are awaited: a container-backed workspace answers a glob and a - read over a synchronous HTTP client, so calling them from a coroutine held the - event loop for a round trip per file. The backends below stay synchronous on - purpose - `ensure_async` wraps one, which is exactly the arrangement in - production for a `state` workspace. + Both functions are awaited and work in the run's workspace. Most tests below + use a stored document, which is a `state` agent's; the ones about a shell use + a real directory, which lists itself with `find` as a container does. """ async def test_a_file_the_turn_wrote_is_sent(self): - backend = StateBackend() - before = await workspace_snapshot(backend) - backend.write("/report.csv", "month,total") + document = StateBackend() + workspace = document_workspace(document) + before = await workspace_snapshot(workspace) + _write(document, "/report.csv", "month,total") - delivered = await files_written(backend, before) + delivered = await files_written(workspace, before) assert [a.filename for a in delivered.attachments] == ["report.csv"] assert delivered.attachments[0].content == b"month,total" @@ -463,86 +479,95 @@ async def test_a_produced_file_carries_its_own_type(self): """A chart is the commonest thing an agent writes, and every file used to go out as `application/octet-stream` - so the picture somebody asked for arrived as a blob they had to download to identify.""" - backend = StateBackend() - before = await workspace_snapshot(backend) - backend.write("/chart.png", b"\x89PNG\r\n") + document = StateBackend() + workspace = document_workspace(document) + before = await workspace_snapshot(workspace) + _write(document, "/chart.png", b"\x89PNG\r\n") - delivered = await files_written(backend, before) + delivered = await files_written(workspace, before) assert delivered.attachments[0].mime_type == "image/png" async def test_a_name_with_no_recognisable_suffix_stays_opaque(self): - backend = StateBackend() - before = await workspace_snapshot(backend) - backend.write("/dump", "raw") + document = StateBackend() + workspace = document_workspace(document) + before = await workspace_snapshot(workspace) + _write(document, "/dump", "raw") - delivered = await files_written(backend, before) + delivered = await files_written(workspace, before) assert delivered.attachments[0].mime_type == "application/octet-stream" async def test_a_dotfile_that_was_already_there_is_not_sent_again(self): - """`glob_info("**/*")` does not match a leading dot, so a `.env` written + """A dotfile is listed like any other, so a `.env` written before the turn was absent from the snapshot - and rewriting it during the turn read as new and would have been posted into the channel.""" - backend = StateBackend() - backend.write("/.env", "A=1") - before = await workspace_snapshot(backend) - backend.write("/.env", "A=2") + document = StateBackend() + workspace = document_workspace(document) + _write(document, "/.env", "A=1") + before = await workspace_snapshot(workspace) + _write(document, "/.env", "A=2") - assert (await files_written(backend, before)).attachments == [] + assert (await files_written(workspace, before)).attachments == [] async def test_a_file_that_was_already_there_is_not_sent_again(self): """Rewriting a script it is iterating on is ordinary work, and posting it every turn would fill the channel with the same attachment.""" - backend = StateBackend() - backend.write("/run.py", "print(1)") - before = await workspace_snapshot(backend) - backend.write("/run.py", "print(2)") + document = StateBackend() + workspace = document_workspace(document) + _write(document, "/run.py", "print(1)") + before = await workspace_snapshot(workspace) + _write(document, "/run.py", "print(2)") - assert (await files_written(backend, before)).attachments == [] + assert (await files_written(workspace, before)).attachments == [] async def test_the_users_own_upload_is_not_posted_back_at_them(self): - backend = StateBackend() - before = await workspace_snapshot(backend) - backend.write("/uploads/theirs.csv", "a,b") + document = StateBackend() + workspace = document_workspace(document) + before = await workspace_snapshot(workspace) + _write(document, "/uploads/theirs.csv", "a,b") - assert (await files_written(backend, before)).attachments == [] + assert (await files_written(workspace, before)).attachments == [] async def test_a_materialised_skill_is_not_the_agents_work(self): - backend = StateBackend() - before = await workspace_snapshot(backend) - backend.write("/workspace/skills/refunds/SKILL.md", "---\nname: refunds\n---\n\nbody") + document = StateBackend() + workspace = document_workspace(document) + before = await workspace_snapshot(workspace) + _write(document, "/workspace/skills/refunds/SKILL.md", "---\nname: refunds\n---\n\nbody") - assert (await files_written(backend, before)).attachments == [] + assert (await files_written(workspace, before)).attachments == [] async def test_a_container_listing_a_skill_relatively_is_not_the_agents_work(self): """A container lists its workspace relative to its own root, so the same file the `state` backend calls `/workspace/skills/...` arrives as `skills/...`. One filter has to catch both spellings, or a channel reply posts organizational know-how back as the agent's own work.""" - backend = StateBackend() - before = await workspace_snapshot(backend) - backend.write("skills/refunds/reconcile.py", "print('hi')") + document = StateBackend() + workspace = document_workspace(document) + before = await workspace_snapshot(workspace) + _write(document, "skills/refunds/reconcile.py", "print('hi')") - assert (await files_written(backend, before)).attachments == [] + assert (await files_written(workspace, before)).attachments == [] async def test_a_spilled_tool_return_is_not_the_agents_work(self): """A `tool_output_limits` spill is parked for the model to page through, not produced for the user - posting it would hand a channel the raw oversized return the reduction existed to keep out of sight (#803).""" - backend = StateBackend() - before = await workspace_snapshot(backend) - backend.write("/tool_output/run-1/call-1.0", "y" * 5_000) + document = StateBackend() + workspace = document_workspace(document) + before = await workspace_snapshot(workspace) + _write(document, "/tool_output/run-1/call-1.0", "y" * 5_000) - assert (await files_written(backend, before)).attachments == [] + assert (await files_written(workspace, before)).attachments == [] async def test_a_file_too_large_for_a_reply_is_named_rather_than_dropped(self): """An agent told its file was delivered will tell the user the same.""" - backend = StateBackend() - before = await workspace_snapshot(backend) - backend.write("/huge.csv", "x" * (MAX_OUTBOUND_BYTES + 1)) + document = StateBackend() + workspace = document_workspace(document) + before = await workspace_snapshot(workspace) + _write(document, "/huge.csv", "x" * (MAX_OUTBOUND_BYTES + 1)) - delivered = await files_written(backend, before) + delivered = await files_written(workspace, before) assert delivered.attachments == [] assert delivered.refused == ["/huge.csv"] @@ -550,61 +575,69 @@ async def test_a_file_too_large_for_a_reply_is_named_rather_than_dropped(self): async def test_past_the_per_reply_cap_the_rest_are_named(self): """A turn that writes twelve intermediate CSVs should not post twelve.""" - backend = StateBackend() - before = await workspace_snapshot(backend) + document = StateBackend() + workspace = document_workspace(document) + before = await workspace_snapshot(workspace) for index in range(MAX_OUTBOUND_FILES + 2): - backend.write(f"/out-{index}.csv", "a") + _write(document, f"/out-{index}.csv", "a") - delivered = await files_written(backend, before) + delivered = await files_written(workspace, before) assert len(delivered.attachments) == MAX_OUTBOUND_FILES assert len(delivered.refused) == 2 async def test_nothing_written_is_nothing_said(self): - backend = StateBackend() - before = await workspace_snapshot(backend) + document = StateBackend() + workspace = document_workspace(document) + before = await workspace_snapshot(workspace) - delivered = await files_written(backend, before) + delivered = await files_written(workspace, before) assert delivered.attachments == [] assert delivered.note() == "" - async def test_the_snapshot_is_taken_of_the_working_directory(self): - """Not of the machine, which is what omitting the root asked for. - - The client's default is `/`, which a virtual-path backend reads as the - top of its namespace and a shell reads as the filesystem root - so on a - container this snapshot was 2540 paths of `/proc` and `/usr`, taken twice - per turn, and "what did the agent write" came down to whether `/proc` had - changed (#1039). - """ - asked: list[tuple[str, str]] = [] + async def test_a_workspace_with_a_shell_is_listed_from_its_working_directory(self, tmp_path): + """Not from `/`, which a shell reads as the machine: on a container a snapshot + rooted there was 2540 paths of `/proc` and `/usr`, taken twice per turn, and + "what did the agent write" came down to whether `/proc` had changed (#1039). + And a spill it lists as `./tool_output/...` is still the platform's.""" + (tmp_path / "old.csv").write_text("kept") + workspace = Workspace(LocalWorkspaceBackend(tmp_path)) + before = await workspace_snapshot(workspace) + (tmp_path / "report.csv").write_text("a,b") + (tmp_path / "tool_output" / "run-1").mkdir(parents=True) + (tmp_path / "tool_output" / "run-1" / "call-1.0").write_text("y") + + delivered = await files_written(workspace, before) + + assert before == {"./old.csv"} + assert [a.filename for a in delivered.attachments] == ["report.csv"] - class _Backend: - def glob_info(self, pattern, path="/"): - asked.append((pattern, path)) - return [] + async def test_a_listing_command_that_fails_means_no_snapshot(self, tmp_path): + class _FindFails(WrapperWorkspace): + async def run(self, command, **kwargs): + return CommandResult(exit_code=1, stdout="", stderr="find: no") - await files_written(_Backend(), set()) + workspace = _FindFails(Workspace(LocalWorkspaceBackend(tmp_path))) - assert {path for _, path in asked} == {"."} + assert await workspace_snapshot(workspace) is None async def test_a_file_that_cannot_be_read_is_skipped_rather_than_failing_the_reply(self): - class _Backend: - def glob_info(self, pattern, path="/"): - return [{"path": "/gone.csv", "is_dir": False}] - - def read_bytes(self, path): + class _ReadFails(WrapperWorkspace): + async def read_bytes(self, path): raise RuntimeError("vanished between the listing and the read") - assert (await files_written(_Backend(), set())).attachments == [] + document = StateBackend() + _write(document, "/gone.csv", "a") - async def test_a_workspace_that_cannot_be_listed_means_no_attachments_not_no_reply(self): - class _Broken: - def glob_info(self, pattern, path="/"): - raise RuntimeError("the service is down") + assert ( + await files_written(_ReadFails(document_workspace(document)), set()) + ).attachments == [] - assert await files_written(_Broken(), set()) == DeliveredFiles(attachments=[], refused=[]) + async def test_a_workspace_that_cannot_be_listed_means_no_attachments_not_no_reply(self): + assert await files_written(_ListingFails(document_workspace()), set()) == DeliveredFiles( + attachments=[], refused=[] + ) async def test_a_snapshot_of_an_unreadable_workspace_is_not_an_empty_one(self): """It used to answer `set()`, and that is the unsafe direction. @@ -613,12 +646,7 @@ async def test_a_snapshot_of_an_unreadable_workspace_is_not_an_empty_one(self): mean "nothing to compare against" - it means "the workspace was empty", and every file already in it reads as this turn's output. """ - - class _Broken: - def glob_info(self, pattern, path="/"): - raise RuntimeError("no") - - assert await workspace_snapshot(_Broken()) is None + assert await workspace_snapshot(_ListingFails(document_workspace())) is None async def test_a_turn_whose_snapshot_failed_posts_nothing(self): """The refusal the return type exists for. @@ -629,24 +657,19 @@ async def test_a_turn_whose_snapshot_failed_posts_nothing(self): both calls read the same workspace, so a persistently broken listing fails both and posts nothing anyway. """ - backend = StateBackend() - backend.write("/someone-elses.csv", "month,total") - backend.write("/another.txt", "private") + document = StateBackend() + workspace = document_workspace(document) + _write(document, "/someone-elses.csv", "month,total") + _write(document, "/another.txt", "private") - assert await files_written(backend, None) == DeliveredFiles(attachments=[], refused=[]) + assert await files_written(workspace, None) == DeliveredFiles(attachments=[], refused=[]) async def test_directories_are_not_files(self): - class _WithDirectories: - def glob_info(self, pattern, path="/"): - return [ - {"path": "/out", "is_dir": True}, - {"path": "/out/report.csv", "is_dir": False}, - ] - - def read_bytes(self, path): - return b"a,b" + document = StateBackend() + document.make_dir("/out/empty") + _write(document, "/out/report.csv", "a,b") - delivered = await files_written(_WithDirectories(), set()) + delivered = await files_written(document_workspace(document), set()) assert [a.filename for a in delivered.attachments] == ["report.csv"] diff --git a/backend/tests/test_compaction_capability.py b/backend/tests/test_compaction_capability.py index a51b3768c..d4a9e66ea 100644 --- a/backend/tests/test_compaction_capability.py +++ b/backend/tests/test_compaction_capability.py @@ -38,6 +38,7 @@ from app.agents.capabilities import CapabilityBinding, build, get from app.agents.capabilities.budget import ( + BudgetExceeded, BudgetGuard, BudgetScope, SpendLedger, @@ -76,8 +77,14 @@ def _response(*, input_tokens: int) -> ModelResponse: ) -def _run_context(usage: RunUsage | None = None) -> RunContext[None]: - return RunContext(deps=None, model=TestModel(), usage=usage or RunUsage()) +def _run_context( + usage: RunUsage | None = None, messages: list[ModelMessage] | None = None +) -> RunContext[None]: + """The run a request belongs to. `messages` is its history - the harness + measures what to compact from the run, not from the request it edits.""" + return RunContext( + deps=None, model=TestModel(), usage=usage or RunUsage(), messages=list(messages or []) + ) @dataclass @@ -284,6 +291,32 @@ async def test_a_summary_is_booked_against_the_run_that_paid_for_it(self): assert len(ledger.entries) == 1 assert (ledger.input_tokens, ledger.output_tokens) == (1_200, 300) + @pytest.mark.security + async def test_a_summary_that_reaches_a_cap_refuses_the_request_after_it(self): + """`BudgetGuard.wrap_model_request` encloses this hook since pydantic-ai 2.54, + so its check ran before the summary was bought. Without a second one, the + request the summary was made for would go out past the cap and be billed.""" + ledger = SpendLedger() + exhausted = BudgetGuard( + ledger=ledger, limits=[SpendLimit(scope=BudgetScope.AGENT, limit_usd=Decimal(0))] + ) + capability = MeteredCompaction(wrapped=_Spender(input_tokens=1_200, output_tokens=300)) + + with metered_by(ledger), guarded_by(exhausted), pytest.raises(BudgetExceeded): + await capability.before_model_request(_run_context(), _request_context([])) + + assert ledger.input_tokens == 1_200 + + @pytest.mark.security + async def test_a_strategy_that_paid_nothing_does_not_ask_the_budget_again(self): + """A cap is the next request's to refuse, through `BudgetGuard`: a strategy + that spent nothing has changed nothing the guard already checked.""" + exhausted = BudgetGuard(limits=[SpendLimit(scope=BudgetScope.AGENT, limit_usd=Decimal(0))]) + capability = MeteredCompaction(wrapped=_Spender()) + + with guarded_by(exhausted): + await capability.before_model_request(_run_context(), _request_context([])) + async def test_a_strategy_that_calls_no_model_books_nothing(self): """The zero-LLM strategies are wrapped too, and must stay free.""" ledger = SpendLedger() @@ -346,7 +379,7 @@ async def test_the_wrapped_strategy_still_edits_the_request(self): messages = [_user(f"turn {index}: " + "words " * 20) for index in range(20)] request_context = await capability.before_model_request( - _run_context(), _request_context(list(messages)) + _run_context(messages=messages), _request_context(list(messages)) ) assert len(request_context.messages) < len(messages) @@ -374,10 +407,10 @@ def on(window: int) -> MeteredCompaction[None]: ) roomy = await on(1_000_000).before_model_request( - _run_context(), _request_context(list(history)) + _run_context(messages=history), _request_context(list(history)) ) cramped = await on(128_000).before_model_request( - _run_context(), _request_context(list(history)) + _run_context(messages=history), _request_context(list(history)) ) assert len(roomy.messages) == len(history) @@ -428,7 +461,7 @@ async def test_an_overhead_under_the_trigger_compacts_as_configured(self): capability, history = self._wrapper(10_000, 3_865) compacted = await capability.before_model_request( - _run_context(), _request_context(list(history)) + _run_context(messages=history), _request_context(list(history)) ) assert len(compacted.messages) < len(history) @@ -439,7 +472,7 @@ async def test_an_overhead_past_the_trigger_compacts_nothing(self): capability, history = self._wrapper(5_000, 3_865) compacted = await capability.before_model_request( - _run_context(), _request_context(list(history)) + _run_context(messages=history), _request_context(list(history)) ) assert len(compacted.messages) == len(history) @@ -454,7 +487,7 @@ async def test_a_reading_from_an_earlier_turn_is_enough_to_refuse(self): capability.gauge = ContextGauge(overhead=3_865) compacted = await capability.before_model_request( - _run_context(), _request_context(list(history)) + _run_context(messages=history), _request_context(list(history)) ) assert len(compacted.messages) == len(history) @@ -465,7 +498,7 @@ async def test_without_a_reading_nothing_is_skipped(self): capability, history = self._wrapper(5_000, None) compacted = await capability.before_model_request( - _run_context(), _request_context(list(history)) + _run_context(messages=history), _request_context(list(history)) ) assert len(compacted.messages) < len(history) @@ -539,7 +572,7 @@ async def test_a_strategy_that_buys_nothing_is_left_to_run(self): capability.wrapped.context_window = 5_000 compacted = await capability.before_model_request( - _run_context(), _request_context(list(history)) + _run_context(messages=history), _request_context(list(history)) ) assert len(compacted.messages) < len(history) @@ -828,7 +861,7 @@ async def test_a_parked_tool_call_keeps_the_call_its_return_answers(self): wrapped=build_strategy(_triggers_immediately("sliding_window", keep_messages=3)) ) request_context = await capability.before_model_request( - _run_context(), _request_context(list(history)) + _run_context(messages=history), _request_context(list(history)) ) assert len(request_context.messages) < len(history) diff --git a/backend/tests/test_image_generation.py b/backend/tests/test_image_generation.py index afa917bab..db38509a7 100644 --- a/backend/tests/test_image_generation.py +++ b/backend/tests/test_image_generation.py @@ -11,7 +11,8 @@ from pydantic_ai.messages import BinaryImage from pydantic_ai.models.test import TestModel from pydantic_ai.usage import RunUsage -from pydantic_ai_backends import StateBackend, ensure_async +from pydantic_ai.workspaces import Workspace +from pydantic_ai_backends import StateBackend from app.agents.capabilities import get from app.agents.capabilities._registry import CapabilityBinding @@ -23,9 +24,11 @@ build_image_toolset, parse_generated_image, ) +from app.agents.capabilities.sandbox._capped import CappedStateBackend from app.agents.deps import AgentDeps from app.core.config import settings from app.core.secret_kinds import ApiKeySecret +from tests.workspaces import ShellFailing, document_workspace pytestmark = pytest.mark.anyio @@ -69,14 +72,23 @@ def _fakes(monkeypatch: pytest.MonkeyPatch, tmp_path): _FakeAgent.raise_exc = None -def _ctx(organization_id: Any, *, retry: int = 0, max_retries: int = 1) -> RunContext[AgentDeps]: - return RunContext( +def _ctx( + organization_id: Any, + *, + retry: int = 0, + max_retries: int = 1, + workspace: Workspace | None = None, +) -> RunContext[AgentDeps]: + ctx = RunContext( deps=AgentDeps(organization_id=organization_id), model=TestModel(), usage=RunUsage(), retry=retry, max_retries=max_retries, ) + if workspace is not None: + ctx.workspace = workspace + return ctx async def _generate(toolset: Any, ctx: RunContext[AgentDeps], prompt: str = "a red bicycle") -> str: @@ -112,7 +124,7 @@ def test_the_builder_reads_the_key_and_the_config(): async def test_generating_an_image_stores_it_meters_it_and_returns_a_reference(): organization_id = uuid4() toolset = build_image_toolset( - model_id="openai-responses:gpt-5.4", api_key="k", tool_settings={}, workspace_backend=None + model_id="openai-responses:gpt-5.4", api_key="k", tool_settings={} ) ledger = SpendLedger() with metered_by(ledger): @@ -129,24 +141,50 @@ async def test_generating_an_image_stores_it_meters_it_and_returns_a_reference() async def test_a_generated_image_is_also_written_into_an_open_workspace(): - backend = StateBackend() + document = StateBackend() toolset = build_image_toolset( - model_id="openai-responses:gpt-5.4", - api_key="k", - tool_settings={}, - workspace_backend=backend, + model_id="openai-responses:gpt-5.4", api_key="k", tool_settings={} ) - result = await _generate(toolset, _ctx(uuid4())) + result = await _generate(toolset, _ctx(uuid4(), workspace=document_workspace(document))) image = parse_generated_image(result) assert image is not None and image.workspace_path is not None assert image.workspace_path.startswith(f"{WORKSPACE_OUTPUT_DIR}/") - assert await ensure_async(backend).read_bytes(image.workspace_path) == _IMAGE.data + assert document.read_bytes(image.workspace_path) == _IMAGE.data + + +async def test_a_full_workspace_names_no_path_for_the_image(): + """The image is still stored and linked; a path holding nothing would send the + model to read it.""" + workspace = document_workspace(CappedStateBackend(max_bytes=10)) + toolset = build_image_toolset( + model_id="openai-responses:gpt-5.4", api_key="k", tool_settings={} + ) + + image = parse_generated_image(await _generate(toolset, _ctx(uuid4(), workspace=workspace))) + + assert image is not None and image.url is not None + assert image.workspace_path is None + + +async def test_a_container_that_cannot_take_the_image_names_no_path_for_it(): + """A container's shell fails with `WorkspaceError` where a full document + raises `OSError`; the image was generated and paid for either way.""" + toolset = build_image_toolset( + model_id="openai-responses:gpt-5.4", api_key="k", tool_settings={} + ) + + image = parse_generated_image( + await _generate(toolset, _ctx(uuid4(), workspace=ShellFailing(document_workspace()))) + ) + + assert image is not None and image.url is not None + assert image.workspace_path is None async def test_without_an_organization_the_image_is_generated_but_not_stored(): toolset = build_image_toolset( - model_id="openai-responses:gpt-5.4", api_key="k", tool_settings={}, workspace_backend=None + model_id="openai-responses:gpt-5.4", api_key="k", tool_settings={} ) ledger = SpendLedger() with metered_by(ledger): @@ -162,7 +200,7 @@ async def test_without_an_organization_the_image_is_generated_but_not_stored(): async def test_without_a_key_it_refuses_before_spending_or_storing(): toolset = build_image_toolset( - model_id="openai-responses:gpt-5.4", api_key=None, tool_settings={}, workspace_backend=None + model_id="openai-responses:gpt-5.4", api_key=None, tool_settings={} ) ledger = SpendLedger() with metered_by(ledger), pytest.raises(ModelRetry): @@ -173,7 +211,7 @@ async def test_without_a_key_it_refuses_before_spending_or_storing(): async def test_a_failing_image_model_asks_the_model_to_retry(): _FakeAgent.raise_exc = UserError("that prompt was rejected") toolset = build_image_toolset( - model_id="openai-responses:gpt-5.4", api_key="k", tool_settings={}, workspace_backend=None + model_id="openai-responses:gpt-5.4", api_key="k", tool_settings={} ) with pytest.raises(ModelRetry, match="Image generation failed"): await _generate(toolset, _ctx(uuid4())) @@ -183,7 +221,7 @@ async def test_the_last_attempt_answers_rather_than_ending_the_run(): """A `ModelRetry` past the tool's budget ends the conversation.""" _FakeAgent.raise_exc = UserError("that prompt was rejected") toolset = build_image_toolset( - model_id="openai-responses:gpt-5.4", api_key="k", tool_settings={}, workspace_backend=None + model_id="openai-responses:gpt-5.4", api_key="k", tool_settings={} ) answered = await _generate(toolset, _ctx(uuid4(), retry=1)) diff --git a/backend/tests/test_sandbox_operations.py b/backend/tests/test_sandbox_operations.py index 07f1dd4da..ec66a0638 100644 --- a/backend/tests/test_sandbox_operations.py +++ b/backend/tests/test_sandbox_operations.py @@ -9,18 +9,29 @@ everyone who can see the sandbox, so a file's contents or a command's output in one would turn an audit into a way to read somebody's work - and the service draws that line deliberately, so the product has to as well. + +The recorder wraps a real local workspace in a temporary directory: what it records +is what a real backend answered, including the way a real one fails. """ from __future__ import annotations import uuid from datetime import UTC, datetime, timedelta +from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest +from pydantic_ai.workspaces import ( + LocalWorkspaceBackend, + Workspace, + WorkspaceTimeoutError, + WorkspaceUnavailableError, +) -from app.agents.capabilities.sandbox._recording import RecordingBackend +from app.agents.capabilities.sandbox._recording import RecordingWorkspace +from app.db.models.sandbox_operation import SandboxOperation pytestmark = pytest.mark.anyio @@ -29,255 +40,224 @@ class _Recorded: """A session that keeps what was added, which is the whole assertion.""" def __init__(self) -> None: - self.rows: list[object] = [] + self.rows: list[SandboxOperation] = [] - def add(self, row: object) -> None: + def add(self, row: SandboxOperation) -> None: self.rows.append(row) -def _wrap(backend: object, db: _Recorded | None = None) -> tuple[RecordingBackend, _Recorded]: +def _wrap( + root: Path, db: _Recorded | None = None, *, run_id: uuid.UUID | None = None +) -> tuple[RecordingWorkspace, _Recorded]: session = db or _Recorded() return ( - RecordingBackend( - backend, + RecordingWorkspace( + Workspace(LocalWorkspaceBackend(root)), db=session, # type: ignore[arg-type] - only `add` is used organization_id=uuid.uuid4(), session_key="xc-1", agent_id=uuid.uuid4(), + run_id=run_id, ), session, ) class TestWhatIsRecorded: - async def test_a_write_records_its_path_and_never_its_content(self): + async def test_a_write_records_its_path_and_never_its_content(self, tmp_path: Path): """The single most important assertion in this file.""" - backend = MagicMock() - backend.write = MagicMock(return_value=SimpleNamespace(error=None)) - recorder, session = _wrap(backend) + recorder, session = _wrap(tmp_path) - recorder.write("uploads/report.csv", "month,total\\njan,10") + await recorder.write_text("report.csv", "month,total\njan,10") - [row] = session.rows + (row,) = session.rows assert row.op == "write" - assert row.target == "uploads/report.csv" - assert "month" not in row.detail - assert "jan" not in row.detail - - async def test_a_command_records_itself_and_never_its_output(self): - backend = MagicMock() - backend.execute = MagicMock( - return_value=SimpleNamespace(exit_code=0, output="root:x:0:0:root:/root:/bin/sh") - ) - recorder, session = _wrap(backend) + assert row.target == "report.csv" + assert row.ok is True + assert row.detail == "18 bytes" + assert "jan" not in f"{row.target}{row.detail}" - recorder.execute("cat /etc/passwd") + async def test_a_command_records_itself_and_never_its_output(self, tmp_path: Path): + recorder, session = _wrap(tmp_path) - [row] = session.rows - assert row.target == "cat /etc/passwd" - assert "root" not in row.detail + result = await recorder.run(["echo", "a secret the command printed"]) - async def test_a_read_records_a_size_rather_than_the_bytes(self): - backend = MagicMock() - backend.read = MagicMock(return_value="a secret the model asked for") - recorder, session = _wrap(backend) + assert "secret" in result.stdout + (row,) = session.rows + assert row.op == "execute" + assert row.target == "echo 'a secret the command printed'" + assert row.detail == "" - recorder.read("notes.md") + async def test_a_shell_command_is_recorded_as_written(self, tmp_path: Path): + """The console's `execute` hands the model's command to a shell as one + string, so that string is the operation.""" + recorder, session = _wrap(tmp_path) - [row] = session.rows - assert row.detail == "28 bytes" - assert "secret" not in row.detail + await recorder.run("ls | wc -l", shell=True) # noqa: S604 - the shell form is the case under test - async def test_a_listing_records_how_many_it_found(self): - backend = MagicMock() - backend.ls_info = MagicMock(return_value=[{"path": "a"}, {"path": "b"}]) - recorder, session = _wrap(backend) + assert session.rows[0].target == "ls | wc -l" - recorder.ls_info(".") + async def test_a_read_records_a_size_rather_than_the_bytes(self, tmp_path: Path): + (tmp_path / "notes.txt").write_text("a secret the model asked for") + recorder, session = _wrap(tmp_path) - assert session.rows[0].detail == "2 results" + assert await recorder.read_text("notes.txt") == "a secret the model asked for" - async def test_the_agent_and_the_run_are_named(self): - """The two facts the service's own log cannot carry, and the two somebody - auditing a sandbox actually asks for.""" - backend = MagicMock() - backend.write = MagicMock(return_value=SimpleNamespace(error=None)) - recorder, session = _wrap(backend) - run = uuid.uuid4() - recorder.run_id = run + (row,) = session.rows + assert (row.op, row.target, row.detail) == ("read", "notes.txt", "28 bytes") - recorder.write("a.txt", "x") + async def test_a_listing_records_how_many_it_found(self, tmp_path: Path): + (tmp_path / "a").write_text("") + (tmp_path / "b").write_text("") + recorder, session = _wrap(tmp_path) - assert session.rows[0].run_id == run - assert session.rows[0].agent_id is not None + await recorder.list_dir(".") - async def test_a_command_longer_than_the_column_is_truncated_not_dropped(self): - """A command is whatever the model wrote. A log that lost an operation - because its command was long would be missing the very entry somebody came - for.""" - backend = MagicMock() - backend.execute = MagicMock(return_value=SimpleNamespace(exit_code=0, output="")) - recorder, session = _wrap(backend) + assert (session.rows[0].op, session.rows[0].detail) == ("ls_info", "2 results") - recorder.execute("echo " + "x" * 5_000) - - assert len(session.rows[0].target) == 512 + async def test_a_directory_made_and_a_path_removed_are_operations(self, tmp_path: Path): + """Both change the workspace, which is what the log is for.""" + recorder, session = _wrap(tmp_path) - async def test_a_non_string_target_is_recorded_as_something(self): - backend = MagicMock() - backend.read = MagicMock(return_value="") - recorder, session = _wrap(backend) + await recorder.make_dir("out") + await recorder.remove("out") - recorder.read(None) + assert [(row.op, row.target, row.ok) for row in session.rows] == [ + ("mkdir", "out", True), + ("remove", "out", True), + ] - assert session.rows[0].target == "None" - - async def test_a_call_with_no_arguments_records_an_empty_target(self): - backend = MagicMock() - backend.ls_info = MagicMock(return_value=[]) - recorder, session = _wrap(backend) - - recorder.ls_info() - - assert session.rows[0].target == "" + async def test_the_agent_and_the_run_are_named(self, tmp_path: Path): + """The run every row is attributed to rides in at construction: the runner + mints the run row before it opens the workspace, and without it every row + said run_id=null and an auditor could not link an operation to the + execution that performed it.""" + run_id = uuid.uuid4() + recorder, session = _wrap(tmp_path, run_id=run_id) + await recorder.write_text("a.txt", "x") -class TestWhatIsNotRecorded: - async def test_a_question_is_delegated_untouched(self): - """`exists` is a question rather than an operation, and a log full of them - would bury the writes somebody came to read.""" - backend = MagicMock() - backend.exists = MagicMock(return_value=True) - recorder, session = _wrap(backend) - - assert recorder.exists("a.txt") is True - assert session.rows == [] + (row,) = session.rows + assert row.run_id == run_id + assert row.agent_id is not None + assert row.session_key == "xc-1" - async def test_a_plain_attribute_passes_through(self): - backend = SimpleNamespace(session_id="xc-1") - recorder, _ = _wrap(backend) + async def test_a_command_longer_than_the_column_is_truncated_not_dropped(self, tmp_path: Path): + """A log that dropped an operation because its command was long would be + missing exactly the entry somebody is looking for.""" + recorder, session = _wrap(tmp_path) - assert recorder.session_id == "xc-1" + await recorder.run(["true", "x" * 2000]) + assert len(session.rows[0].target) == 512 -class TestFailuresAndShapes: - async def test_a_refused_write_is_recorded_as_a_failure(self): - """A full document answers rather than raising, so reading every - non-exception as a success would record a write that never happened.""" - backend = MagicMock() - backend.write = MagicMock(return_value=SimpleNamespace(error="workspace is full")) - recorder, session = _wrap(backend) - recorder.write("a.txt", "x") +class TestWhatIsNotRecorded: + async def test_a_question_is_not_an_operation(self, tmp_path: Path): + """`exists` and `stat` ask about the workspace without changing or reading + it, and a log full of them would bury the writes somebody came to read.""" + (tmp_path / "a.txt").write_text("x") + recorder, session = _wrap(tmp_path) - assert session.rows[0].ok is False - # And not the backend's own words: `refused`, written here. - assert session.rows[0].detail == "refused" + assert await recorder.exists("a.txt") is True + assert (await recorder.stat("a.txt")).size == 1 - async def test_a_raising_call_is_recorded_and_re_raised(self): - backend = MagicMock() - backend.execute = MagicMock(side_effect=RuntimeError("the host went away")) - recorder, session = _wrap(backend) + assert session.rows == [] - with pytest.raises(RuntimeError): - recorder.execute("ls") + async def test_the_wrapped_workspace_is_still_what_is_attached(self, tmp_path: Path): + """Recording changes what is logged, not which workspace the run is in.""" + inner = Workspace(LocalWorkspaceBackend(tmp_path)) + recorder = RecordingWorkspace( + inner, + db=_Recorded(), # type: ignore[arg-type] + organization_id=uuid.uuid4(), + session_key="xc-1", + agent_id=None, + ) - assert session.rows[0].ok is False - # The class, not the message: a shell's message is its output. - assert session.rows[0].detail == "RuntimeError" - assert "went away" not in session.rows[0].detail + assert recorder.ref == inner.ref + assert recorder.backend is inner.backend - async def test_an_async_backend_stays_async(self): - """A container's is synchronous and `ensure_async` wraps it later; a - `StateBackend`'s is plain. Returning the wrong shape for `execute` made - `asyncio.to_thread` hand back a coroutine nobody awaited, so the prune - never ran and its caller read the missing exit code as success.""" - backend = MagicMock() - backend.write = AsyncMock(return_value=SimpleNamespace(error=None)) - recorder, session = _wrap(backend) - await recorder.write("a.txt", "x") +class TestFailures: + async def test_a_refused_operation_is_recorded_and_re_raised(self, tmp_path: Path): + """By its class: the message of a filesystem error names the path, and the + message of a client's error can carry the failing request (#423).""" + recorder, session = _wrap(tmp_path) - assert session.rows[0].op == "write" + with pytest.raises(FileNotFoundError): + await recorder.read_bytes("missing.txt") - async def test_an_async_failure_is_recorded_and_re_raised(self): - backend = MagicMock() - backend.write = AsyncMock(side_effect=ValueError("no")) - recorder, session = _wrap(backend) + (row,) = session.rows + assert (row.op, row.ok, row.detail) == ("read", False, "FileNotFoundError") - with pytest.raises(ValueError): - await recorder.write("a.txt", "x") + async def test_a_command_that_times_out_records_the_class_and_not_its_output( + self, tmp_path: Path + ): + """A timeout carries what the command had printed so far; the row must not.""" + recorder, session = _wrap(tmp_path) - assert session.rows[0].detail == "ValueError" + with pytest.raises(WorkspaceTimeoutError): + await recorder.run(["sh", "-c", "echo partial secret; sleep 5"], timeout=0.5) - async def test_a_sync_call_is_not_turned_into_a_coroutine(self): - import inspect + (row,) = session.rows + assert (row.ok, row.detail) == (False, "WorkspaceTimeoutError") - backend = MagicMock() - backend.execute = MagicMock(return_value=SimpleNamespace(exit_code=0, output="")) - recorder, _ = _wrap(backend) + async def test_a_nonzero_exit_is_recorded_as_a_failure_with_its_status(self, tmp_path: Path): + """`false`, a failing compiler, a refused script: a command's failure is + its exit code, carried without an exception - and an audit log that painted + those green would say the sandbox did what it visibly did not. The numeric + status is the one safe detail (#423).""" + recorder, session = _wrap(tmp_path) - answered = recorder.execute("ls") + result = await recorder.run(["sh", "-c", "echo boom; exit 2"]) - assert not inspect.isawaitable(answered) - assert answered.exit_code == 0 + assert result.exit_code == 2 + (row,) = session.rows + assert (row.ok, row.detail) == (False, "exit 2") - async def test_a_session_that_refuses_the_row_does_not_fail_the_operation(self): + async def test_a_session_that_refuses_the_row_does_not_fail_the_operation(self, tmp_path: Path): """The log is an audit and the operation is the work: losing an entry is a log line, not a reason to fail an agent's write.""" - class _Broken: - def add(self, row: object) -> None: + class _Broken(_Recorded): + def add(self, row: SandboxOperation) -> None: raise RuntimeError("no session") - backend = MagicMock() - backend.write = MagicMock(return_value=SimpleNamespace(error=None)) - recorder = RecordingBackend( - backend, - db=_Broken(), # type: ignore[arg-type] - organization_id=uuid.uuid4(), - session_key="xc-1", - agent_id=None, - ) + recorder, _ = _wrap(tmp_path, db=_Broken()) - written = recorder.write("a.txt", "x") - assert written.error is None + await recorder.write_text("a.txt", "x") - async def test_a_nonzero_exit_is_recorded_as_a_failure_with_its_status(self): - """`false`, a failing compiler, a refused script: a command's failure is - its exit code, carried without an `error` and without an exception - and - an audit log that painted those green would say the sandbox did what it - visibly did not. The numeric status is the one safe detail (#423).""" - backend = MagicMock() - backend.execute = MagicMock(return_value=SimpleNamespace(exit_code=2, output="boom")) - recorder, session = _wrap(backend) - - recorder.execute("false") - - assert session.rows[0].ok is False - assert session.rows[0].detail == "exit 2" - assert "boom" not in session.rows[0].detail - - async def test_the_run_the_workspace_was_opened_for_names_every_row(self): - """The runner mints the run row before it opens the workspace, so the id - rides in at construction - without it every row said run_id=null and an - auditor could not link an operation to the execution that performed it.""" - run_id = uuid.uuid4() - backend = MagicMock() - backend.write = MagicMock(return_value=SimpleNamespace(error=None)) - session = _Recorded() - recorder = RecordingBackend( - backend, - db=session, # type: ignore[arg-type] - organization_id=uuid.uuid4(), - session_key="xc-1", - agent_id=None, - run_id=run_id, - ) + assert (tmp_path / "a.txt").read_text() == "x" - recorder.write("a.txt", "x") - assert session.rows[0].run_id == run_id +class TestALostSession: + @pytest.mark.parametrize( + ("question", "args"), + [ + ("working_dir", ()), + ("stat", ("a.txt",)), + ("exists", ("a.txt",)), + ("realpath", ("a.txt",)), + ], + ) + async def test_a_question_that_finds_the_session_gone_marks_it_lost( + self, tmp_path: Path, question: str, args: tuple[str, ...] + ): + """The console's permission guard asks these before every read and write, so + a session purged mid-turn is often first seen by one of them; a close that + missed it would keep the session for the next turn to fail on again.""" + recorder, session = _wrap(tmp_path) + gone = AsyncMock(side_effect=WorkspaceUnavailableError("purged")) + + with ( + patch.object(recorder.wrapped, question, gone), + pytest.raises(WorkspaceUnavailableError), + ): + await getattr(recorder, question)(*args) + + assert recorder.lost + assert session.rows == [] class TestReadingTheLog: diff --git a/backend/tests/test_sandbox_runtime_catalog.py b/backend/tests/test_sandbox_runtime_catalog.py index fca7775c7..05ed9ec27 100644 --- a/backend/tests/test_sandbox_runtime_catalog.py +++ b/backend/tests/test_sandbox_runtime_catalog.py @@ -23,6 +23,7 @@ runtime_briefing, runtime_parses_documents, ) +from tests.workspaces import document_workspace def _sandboxd_environment(name: str) -> dict[str, Any]: @@ -448,7 +449,7 @@ def _workspace(briefing: str | None) -> Any: from app.services.sandbox_workspace import OpenWorkspace return OpenWorkspace( - backend=object(), + workspace=document_workspace(), kind="service", scope="conversation", scope_key="xc-1234", diff --git a/backend/tests/test_sandbox_workspace.py b/backend/tests/test_sandbox_workspace.py index c33e7bb1f..a76b1c8ef 100644 --- a/backend/tests/test_sandbox_workspace.py +++ b/backend/tests/test_sandbox_workspace.py @@ -12,22 +12,38 @@ from __future__ import annotations +import errno import logging import os -import subprocess import threading -from types import SimpleNamespace +from collections.abc import Mapping +from pathlib import Path +from typing import Any from unittest.mock import AsyncMock, MagicMock from uuid import UUID, uuid4 import pytest -from pydantic_ai_backends import FileInfo, StateBackend +from pydantic_ai import Agent +from pydantic_ai.capabilities import AbstractCapability, CombinedCapability +from pydantic_ai.messages import ModelMessage, ModelResponse, TextPart, ToolCallPart, ToolReturnPart +from pydantic_ai.models.function import AgentInfo, FunctionModel +from pydantic_ai.toolsets import FunctionToolset +from pydantic_ai.toolsets.abstract import ToolsetTool +from pydantic_ai.workspaces import ( + CommandResult, + LocalWorkspaceBackend, + WorkspaceCommand, + WorkspaceRef, + WorkspaceUnavailableError, +) +from pydantic_ai_backends import ConsoleCapability, FileInfo, StateBackend from app.agents.capabilities import build as build_capabilities from app.agents.capabilities import get as get_capability from app.agents.capabilities._registry import CapabilityBinding from app.agents.capabilities.approval import approval_required_tools from app.agents.capabilities.sandbox import SandboxConfig +from app.agents.capabilities.sandbox._capability import build_workspace from app.agents.capabilities.sandbox._capped import CappedStateBackend, document_size from app.agents.capabilities.sandbox._identity import ( MAX_SESSION_ID, @@ -35,7 +51,8 @@ WorkspaceScopeUnavailable, scope_key, ) -from app.agents.capabilities.tool_output_limits import OVERFLOW_PREFIX, BackendOverflowStore +from app.agents.capabilities.sandbox._recording import RecordingWorkspace +from app.agents.capabilities.tool_output_limits import OVERFLOW_PREFIX, WorkspaceOverflowStore from app.agents.spec import AgentSpec from app.core.exceptions import BadRequestError, NotFoundError from app.core.permissions import AuthContext, OrgRoleName @@ -47,6 +64,7 @@ browsable, sandbox_config, ) +from tests.workspaces import document_workspace pytestmark = pytest.mark.anyio @@ -279,96 +297,102 @@ def test_threads_in_one_channel_share_it(self): class TestTheStorageCeiling: def test_a_write_past_the_ceiling_is_refused_and_rolled_back(self): - backend = CappedStateBackend(StateBackend(), max_bytes=400) - backend.write("/keep.txt", "hello") - - result = backend.write("/huge.txt", "x" * 5000) - - assert result.error is not None - assert "workspace is full" in result.error - assert backend.exists("/keep.txt") - assert not backend.exists("/huge.txt") - - def test_an_edit_past_the_ceiling_leaves_the_file_as_it_was(self): - backend = CappedStateBackend(StateBackend(), max_bytes=400) - backend.write("/notes.txt", "hello") + document = CappedStateBackend(max_bytes=400) + document.write_bytes("/keep.txt", b"hello") - result = backend.edit("/notes.txt", "hello", "y" * 5000) + with pytest.raises(OSError, match="workspace is full") as refused: + document.write_bytes("/huge.txt", b"x" * 5000) - assert result.error is not None - assert "hello" in backend.read("/notes.txt") + assert refused.value.errno == errno.ENOSPC + assert document.exists("/keep.txt") + assert not document.exists("/huge.txt") - def test_an_edit_inside_the_ceiling_goes_through(self): - backend = CappedStateBackend(StateBackend(), max_bytes=4096) - backend.write("/notes.txt", "hello world") + def test_a_refused_write_records_none_of_the_directories_it_would_have_made(self): + """A write records its parents; refused, it must leave the listing as it + was, or the next turn's `ls` shows empty folders for a file that never + existed.""" + document = CappedStateBackend(max_bytes=400) - result = backend.edit("/notes.txt", "world", "there") + with pytest.raises(OSError): + document.write_bytes("/reports/2026/huge.txt", b"x" * 5000) - assert result.error is None - assert result.occurrences == 1 - assert "hello there" in backend.read("/notes.txt") + assert document.directories == set() + assert not document.exists("/reports") def test_the_advice_names_only_what_the_agent_can_actually_do(self): """It used to say "Delete or shorten something first". - There is no delete: `StateBackend` exposes none and `WORKSPACE_TOOLS` - declares none, so the first half of the advice sent the model looking for - a tool that does not exist. Shortening and overwriting are the two moves - it has. + The console has no delete, so the first half of the advice sent the model + looking for a tool that does not exist. Shortening and overwriting are the + two moves it has. """ - backend = CappedStateBackend(StateBackend(), max_bytes=400) + document = CappedStateBackend(max_bytes=400) - result = backend.write("/huge.txt", "x" * 5000) + with pytest.raises(OSError) as refused: + document.write_bytes("/huge.txt", b"x" * 5000) - assert result.error is not None - assert "delete" not in result.error.lower() - assert "Shorten or overwrite" in result.error + assert "delete" not in str(refused.value).lower() + assert "Shorten or overwrite" in str(refused.value) def test_overwriting_a_big_file_with_a_small_one_frees_the_room(self): - """The move the message now recommends, so it had better work - the check - is on the resulting size, so a write that shrinks the document passes.""" - backend = CappedStateBackend(StateBackend(), max_bytes=400) - backend.write("/big.txt", "x" * 200) + """The move the message recommends, so it had better work - the check is + on the resulting size, so a write that shrinks the document passes.""" + document = CappedStateBackend(max_bytes=400) + document.write_bytes("/big.txt", b"x" * 200) + + document.write_bytes("/big.txt", b"small") + + assert document.read_bytes("/big.txt") == b"small" + + def test_a_document_stored_under_a_higher_ceiling_can_only_shrink(self): + """`SANDBOX_STATE_MAX_BYTES` lowered beneath a stored document: growing it + is refused, shrinking it is not.""" + stored = StateBackend() + stored.write_bytes("/big.txt", b"x" * 1000) + document = CappedStateBackend(stored.files, max_bytes=400) - result = backend.write("/big.txt", "small") + with pytest.raises(OSError): + document.write_bytes("/more.txt", b"y") + document.write_bytes("/big.txt", b"small") - assert result.error is None - assert backend.read("/big.txt").strip().endswith("small") + assert document_size(document.files) <= 400 - def test_a_failing_write_is_reported_rather_than_measured(self): - """A path the backend rejects never reaches the ceiling check.""" - backend = CappedStateBackend(StateBackend(), max_bytes=400) + def test_a_failing_write_is_reported_as_itself_rather_than_measured(self): + """A path the document rejects never reaches the ceiling check.""" + document = CappedStateBackend(max_bytes=400) + document.make_dir("/out") - result = backend.write("../escape.txt", "x") + with pytest.raises(IsADirectoryError): + document.write_bytes("/out", b"x") - assert result.error is not None - assert "workspace is full" not in result.error + async def test_the_model_reads_the_refusal_through_the_workspace(self): + """Refused on the way in, so the tool call that wrote it fails - rather + than the flush dropping the file after the model was told it was kept.""" + workspace = document_workspace(CappedStateBackend(max_bytes=400)) - def test_an_edit_of_a_missing_file_is_reported_unchanged(self): - backend = CappedStateBackend(StateBackend(), max_bytes=400) + with pytest.raises(OSError, match="workspace is full"): + await workspace.write_text("/huge.txt", "x" * 5000) - result = backend.edit("/nothing.txt", "a", "b") + assert not await workspace.exists("/huge.txt") - assert result.error is not None - assert "not found" in result.error + def test_directories_count_against_the_ceiling(self): + """Every parent of a path is a directory stored beside the files, so a + path nested deep enough would otherwise grow the row uncounted.""" + document = CappedStateBackend(max_bytes=4096) + deep = "/" + "/".join(f"level{n}" for n in range(200)) + "/f.txt" - def test_the_read_side_is_delegated_untouched(self): - backend = CappedStateBackend(StateBackend(), max_bytes=4096) - backend.write("/src/app.py", "print('hi')\nprint('there')") + with pytest.raises(OSError, match="workspace is full"): + document.write_bytes(deep, b"x") - assert backend.exists("/src/app.py") - assert [entry["name"] for entry in backend.ls_info("/src")] == ["app.py"] - assert backend.read_bytes("/src/app.py").startswith(b"print") - assert "print" in backend.read("/src/app.py") - assert [entry["name"] for entry in backend.glob_info("**/*.py")] == ["app.py"] - assert backend.grep_raw("there") - assert "CappedStateBackend" in repr(backend) + assert document.files == {} + assert document.directories == set() def test_size_is_measured_as_the_document_that_gets_stored(self): - backend = StateBackend() - backend.write("/a.txt", "x") + document = StateBackend() + document.write_bytes("/a.txt", b"x") - assert document_size(backend.files) > 0 + assert document_size(document.files) > 0 + assert "CappedStateBackend" in repr(CappedStateBackend(max_bytes=1)) class TestReadingTheSpec: @@ -429,25 +453,35 @@ def test_renaming_a_tool_keeps_its_gate(self): assert "execute" not in gated +def _console_tools(built: list[AbstractCapability[Any]]) -> dict[str, ToolsetTool[Any]]: + """The tools the sandbox binding offers the model, by id. + + The binding is a combination - a workspace for the runs nobody opened one for, + and the console - and only the console has tools. + """ + (combined,) = built + assert isinstance(combined, CombinedCapability) + (console,) = [c for c in combined.capabilities if isinstance(c, ConsoleCapability)] + toolset = console.get_toolset() + assert isinstance(toolset, FunctionToolset) + return toolset.tools + + class TestBuildingTheCapability: def test_without_a_backend_the_workspace_is_in_memory(self): """A preview has nowhere durable to write, which is not an error.""" - built = build_capabilities([CapabilityBinding(capability_id="sandbox")]) - - toolset = built[0].get_toolset() + tools = _console_tools(build_capabilities([CapabilityBinding(capability_id="sandbox")])) - assert toolset is not None - assert "write_file" in toolset.tools + assert "write_file" in tools def test_turning_the_shell_off_removes_it_rather_than_gating_it(self): - built = build_capabilities( - [CapabilityBinding(capability_id="sandbox", config={"include_execute": False})] + tools = _console_tools( + build_capabilities( + [CapabilityBinding(capability_id="sandbox", config={"include_execute": False})] + ) ) - toolset = built[0].get_toolset() - - assert toolset is not None - assert "execute" not in toolset.tools + assert "execute" not in tools def test_the_catalog_shows_the_first_sentence_the_model_reads(self): """One source, two lengths - not two copies, which drift unreported. @@ -462,12 +496,10 @@ def test_the_catalog_shows_the_first_sentence_the_model_reads(self): product already sends - `create_chart` included. """ definition = get_capability("sandbox") - built = build_capabilities([CapabilityBinding(capability_id="sandbox")]) - toolset = built[0].get_toolset() + tools = _console_tools(build_capabilities([CapabilityBinding(capability_id="sandbox")])) - assert toolset is not None for tool in definition.tools: - described = toolset.tools[tool.id].tool_def.description + described = tools[tool.id].tool_def.description assert described is not None assert described.startswith(f"{tool.description}") @@ -481,20 +513,16 @@ def test_the_catalog_shows_a_sentence_rather_than_the_whole_prompt(self): def test_the_model_is_not_told_how_to_use_git_in_a_scratch_workspace(self): """`profile="agent"` - the workspace dies with its conversation.""" - built = build_capabilities([CapabilityBinding(capability_id="sandbox")]) - toolset = built[0].get_toolset() + tools = _console_tools(build_capabilities([CapabilityBinding(capability_id="sandbox")])) - assert toolset is not None - described = toolset.tools["execute"].tool_def.description + described = tools["execute"].tool_def.description assert described is not None assert "git add -A" not in described def test_the_background_shells_are_not_offered(self): - built = build_capabilities([CapabilityBinding(capability_id="sandbox")]) - toolset = built[0].get_toolset() + tools = _console_tools(build_capabilities([CapabilityBinding(capability_id="sandbox")])) - assert toolset is not None - assert "run_in_background" not in toolset.tools + assert "run_in_background" not in tools def test_no_backend_demands_a_credential_on_the_binding(self): """The credential moved to the connection, and that is the point. @@ -532,7 +560,7 @@ async def test_a_stored_document_comes_back_on_the_next_turn( self, monkeypatch, mock_db_session ): stored = StateBackend() - stored.write("/report.md", "the numbers") + stored.write_bytes("/report.md", b"the numbers") row = _row(files=dict(stored.files)) monkeypatch.setattr(workspace_repo, "get_by_key", AsyncMock(return_value=row)) monkeypatch.setattr(workspace_repo, "touch", AsyncMock(return_value=row)) @@ -541,7 +569,7 @@ async def test_a_stored_document_comes_back_on_the_next_turn( workspace = await service.open(_spec(), ctx=_ctx(), identity=_identity()) assert workspace is not None - assert "the numbers" in workspace.backend.read("/report.md") + assert "the numbers" in await workspace.workspace.read_text("/report.md") async def test_closing_stores_what_the_run_wrote(self, monkeypatch, mock_db_session): row = _row() @@ -554,12 +582,48 @@ async def test_closing_stores_what_the_run_wrote(self, monkeypatch, mock_db_sess workspace = await service.open(_spec(), ctx=_ctx(), identity=_identity()) assert workspace is not None - workspace.backend.write("/notes.txt", "kept") + await workspace.workspace.write_text("/notes.txt", "kept") await service.close(workspace) assert "/notes.txt" in saved.await_args.kwargs["files"] assert saved.await_args.kwargs["bytes_total"] > 0 + async def test_a_directory_the_agent_made_is_kept_beside_its_files( + self, monkeypatch, mock_db_session + ): + """`mkdir out` with nothing in it yet is still the agent's work: the next + turn's listing has to show it, so the document stores directories too.""" + row = _row() + monkeypatch.setattr(workspace_repo, "get_by_key", AsyncMock(return_value=None)) + monkeypatch.setattr(workspace_repo, "create", AsyncMock(return_value=row)) + saved = AsyncMock(return_value=row) + monkeypatch.setattr(workspace_repo, "save_files", saved) + mock_db_session.get = AsyncMock(return_value=row) + service = SandboxWorkspaceService(mock_db_session) + + workspace = await service.open(_spec(), ctx=_ctx(), identity=_identity()) + assert workspace is not None + await workspace.workspace.make_dir("/out") + await WorkspaceOverflowStore(workspace.workspace).write("run-1/call-1.0", b"y") + await service.close(workspace) + + # The spill's directories go with the spill. + assert saved.await_args.kwargs["directories"] == ["/out"] + + async def test_a_stored_directory_comes_back_on_the_next_turn( + self, monkeypatch, mock_db_session + ): + row = _row(files={}, directories=["/out"]) + monkeypatch.setattr(workspace_repo, "get_by_key", AsyncMock(return_value=row)) + monkeypatch.setattr(workspace_repo, "touch", AsyncMock(return_value=row)) + service = SandboxWorkspaceService(mock_db_session) + + workspace = await service.open(_spec(), ctx=_ctx(), identity=_identity()) + + assert workspace is not None + [entry] = await workspace.workspace.list_dir("/") + assert (entry.name, entry.is_dir) == ("out", True) + async def test_closing_nothing_is_not_an_error(self, mock_db_session): await SandboxWorkspaceService(mock_db_session).close(None) @@ -585,8 +649,8 @@ async def test_a_spill_is_not_persisted_so_it_cannot_outlive_the_run( _spec(session_scope="user"), ctx=_ctx(), identity=_identity() ) assert workspace is not None - workspace.backend.write("/report.md", "the agent's own work") - store = BackendOverflowStore(workspace.backend) + await workspace.workspace.write_text("/report.md", "the agent's own work") + store = WorkspaceOverflowStore(workspace.workspace) await store.write("run-1/call-1.0", b"y" * 5_000) await service.close(workspace) @@ -614,7 +678,9 @@ async def test_a_parked_run_keeps_its_spills_for_the_run_that_resumes_it( _spec(session_scope="user"), ctx=_ctx(), identity=_identity() ) assert workspace is not None - handle = await BackendOverflowStore(workspace.backend).write("run-1/call-1.0", b"y" * 50) + handle = await WorkspaceOverflowStore(workspace.workspace).write( + "run-1/call-1.0", b"y" * 50 + ) await service.close(workspace, keep_spills=True) @@ -640,7 +706,7 @@ async def test_a_flush_overtaken_by_another_run_says_which_paths_it_dropped( _spec(session_scope="agent"), ctx=_ctx(), identity=_identity() ) assert workspace is not None - workspace.backend.write("/mine.txt", "this run's work") + await workspace.workspace.write_text("/mine.txt", "this run's work") # Somebody else finished in between: the committed row has moved on, and # holds a file this run never saw. overtaken = _row(version=5, files={"/theirs.txt": {"content": ["gone"]}}) @@ -667,7 +733,7 @@ async def test_an_uncontested_flush_says_nothing(self, monkeypatch, mock_db_sess workspace = await service.open(_spec(), ctx=_ctx(), identity=_identity()) assert workspace is not None - workspace.backend.write("/mine.txt", "work") + await workspace.workspace.write_text("/mine.txt", "work") with caplog.at_level(logging.WARNING): await service.close(workspace) @@ -709,9 +775,13 @@ async def test_the_skills_tree_from_before_the_move_is_not_persisted_again( workspace = await service.open(_spec(), ctx=_ctx(), identity=_identity()) assert workspace is not None - workspace.backend.write("/skills/refunds/SKILL.md", "the copy from before the move") - workspace.backend.write("/workspace/skills/refunds/SKILL.md", "the one this run wrote") - workspace.backend.write("/report.csv", "the agent's own work") + await workspace.workspace.write_text( + "/skills/refunds/SKILL.md", "the copy from before the move" + ) + await workspace.workspace.write_text( + "/workspace/skills/refunds/SKILL.md", "the one this run wrote" + ) + await workspace.workspace.write_text("/report.csv", "the agent's own work") await service.close(workspace) kept = saved.await_args.kwargs["files"] @@ -726,7 +796,7 @@ async def test_a_run_scoped_state_workspace_is_not_stored(self, monkeypatch, moc workspace = await service.open(_spec(session_scope="run"), ctx=_ctx(), identity=_identity()) assert workspace is not None - workspace.backend.write("/scratch.txt", "gone after this") + await workspace.workspace.write_text("/scratch.txt", "gone after this") await service.close(workspace) saved.assert_not_called() @@ -911,8 +981,8 @@ def test_a_dotfile_is_listed(self): from app.services.sandbox_workspace import stored_entries stored = StateBackend() - stored.write("/.env", "A=1") - stored.write("/notes.md", "x") + stored.write_bytes("/.env", b"A=1") + stored.write_bytes("/notes.md", b"x") assert [str(entry["path"]) for entry in stored_entries(dict(stored.files))] == [ "/.env", @@ -923,7 +993,7 @@ def test_a_dotfile_in_a_subdirectory_is_listed_too(self): from app.services.sandbox_workspace import stored_entries stored = StateBackend() - stored.write("/workspace/skills/refunds/.keep", "") + stored.write_bytes("/workspace/skills/refunds/.keep", b"") assert [str(entry["path"]) for entry in stored_entries(dict(stored.files))] == [ "/workspace/skills/refunds/.keep" @@ -933,7 +1003,7 @@ def test_nothing_is_listed_twice_when_both_patterns_match(self): from app.services.sandbox_workspace import stored_entries stored = StateBackend() - stored.write("/report.csv", "a,b") + stored.write_bytes("/report.csv", b"a,b") assert len(stored_entries(dict(stored.files))) == 1 @@ -943,8 +1013,8 @@ def test_a_file_inside_a_dot_directory_stays_out(self): from app.services.sandbox_workspace import stored_entries stored = StateBackend() - stored.write("/.git/config", "[core]") - stored.write("/notes.md", "x") + stored.write_bytes("/.git/config", b"[core]") + stored.write_bytes("/notes.md", b"x") assert [str(entry["path"]) for entry in stored_entries(dict(stored.files))] == ["/notes.md"] @@ -954,7 +1024,7 @@ def test_a_stored_file_reports_when_it_was_modified(self): from app.services.sandbox_workspace import stored_entries stored = StateBackend() - stored.write("/report.md", "# findings") + stored.write_bytes("/report.md", b"# findings") (entry,) = stored_entries(dict(stored.files)) @@ -979,7 +1049,7 @@ async def test_a_stored_file_comes_back_as_the_bytes_it_was_written_as( self, monkeypatch, mock_db_session ): stored = StateBackend() - stored.write("/report.csv", "month,total") + stored.write_bytes("/report.csv", b"month,total") row = _row(files=dict(stored.files)) monkeypatch.setattr(workspace_repo, "get", AsyncMock(return_value=row)) @@ -1179,7 +1249,7 @@ async def test_a_dotfile_is_readable_even_though_no_pattern_lists_it( an existence oracle answered "no such file" for a file that reads fine. A stored workspace has a real oracle - `exists` - and that is what decides.""" stored = StateBackend() - stored.write("/.env", "OPENAI_API_KEY=sk-x") + stored.write_bytes("/.env", b"OPENAI_API_KEY=sk-x") row = _row(files=dict(stored.files)) monkeypatch.setattr(workspace_repo, "get", AsyncMock(return_value=row)) @@ -1231,7 +1301,7 @@ class TestServingAConversationsFileAsBytes: async def test_the_bytes_come_back(self, monkeypatch, mock_db_session): stored = StateBackend() - stored.write("/report.csv", "month,total") + stored.write_bytes("/report.csv", b"month,total") row = _row(files=dict(stored.files)) monkeypatch.setattr(workspace_repo, "list_for_conversation", AsyncMock(return_value=[row])) @@ -1297,9 +1367,9 @@ async def test_every_visible_workspaces_files_arrive_in_one_list( from app.repositories import agent as agent_repo first = StateBackend() - first.write("/report.csv", "month,total") + first.write_bytes("/report.csv", b"month,total") second = StateBackend() - second.write("/notes.md", "hello") + second.write_bytes("/notes.md", b"hello") monkeypatch.setattr( workspace_repo, "list_for_reader", @@ -1320,7 +1390,7 @@ async def test_directories_are_not_files(self, monkeypatch, mock_db_session): from app.repositories import agent as agent_repo stored = StateBackend() - stored.write("/out/report.csv", "a,b") + stored.write_bytes("/out/report.csv", b"a,b") monkeypatch.setattr( workspace_repo, "list_for_reader", @@ -1358,7 +1428,7 @@ async def test_one_unreadable_workspace_does_not_empty_the_list( from app.repositories import agent as agent_repo stored = StateBackend() - stored.write("/report.csv", "a") + stored.write_bytes("/report.csv", b"a") rows = [_row(files=dict(stored.files)), _row(backend="service", connection_id=uuid4())] monkeypatch.setattr(workspace_repo, "list_for_reader", AsyncMock(return_value=rows)) monkeypatch.setattr(agent_repo, "get_many", AsyncMock(return_value={})) @@ -1502,7 +1572,7 @@ async def test_each_file_names_the_workspace_it_came_from(self, monkeypatch, moc from app.repositories import agent as agent_repo stored = StateBackend() - stored.write("/report.csv", "a") + stored.write_bytes("/report.csv", b"a") row = _row(files=dict(stored.files), scope="agent") agent = MagicMock(id=row.agent_id) agent.name = "Analyst" @@ -1524,7 +1594,7 @@ async def test_a_stored_text_file_carries_its_first_lines(self, monkeypatch, moc from app.repositories import agent as agent_repo stored = StateBackend() - stored.write("/report.md", "# Findings\n" + "x" * 500) + stored.write_bytes("/report.md", ("# Findings\n" + "x" * 500).encode()) monkeypatch.setattr( workspace_repo, "list_for_reader", @@ -1546,7 +1616,7 @@ def test_an_empty_file_previews_as_nothing(self): from app.services.sandbox_workspace import stored_preview stored = StateBackend() - stored.write("/empty.txt", "") + stored.write_bytes("/empty.txt", b"") assert stored_preview(dict(stored.files["/empty.txt"])) is None @@ -1556,7 +1626,7 @@ async def test_binary_content_previews_as_nothing_rather_than_noise( from app.repositories import agent as agent_repo stored = StateBackend() - stored.write("/chart.png", b"\x89PNG\r\n\x1a\n....") + stored.write_bytes("/chart.png", b"\x89PNG\r\n\x1a\n....") monkeypatch.setattr( workspace_repo, "list_for_reader", @@ -1636,7 +1706,7 @@ def test_a_stored_image_becomes_a_scaled_data_uri(self): from app.services.sandbox_workspace import THUMBNAIL_BOX, stored_thumbnail stored = StateBackend() - stored.write("/chart.png", _png()) + stored.write_bytes("/chart.png", _png()) uri = stored_thumbnail("/chart.png", dict(stored.files["/chart.png"])) @@ -1651,7 +1721,7 @@ def test_a_text_file_has_no_thumbnail(self): from app.services.sandbox_workspace import stored_thumbnail stored = StateBackend() - stored.write("/report.md", "# Findings") + stored.write_bytes("/report.md", b"# Findings") assert stored_thumbnail("/report.md", dict(stored.files["/report.md"])) is None @@ -1667,7 +1737,7 @@ def test_a_binary_that_is_not_an_offered_image_is_never_decoded(self): from app.services.sandbox_workspace import stored_thumbnail stored = StateBackend() - stored.write("/archive.zip", _png()) + stored.write_bytes("/archive.zip", _png()) assert stored_thumbnail("/archive.zip", dict(stored.files["/archive.zip"])) is None @@ -1675,7 +1745,7 @@ def test_an_image_too_large_to_scale_keeps_its_mark(self, monkeypatch): from app.services import sandbox_workspace stored = StateBackend() - stored.write("/photo.png", _png()) + stored.write_bytes("/photo.png", _png()) monkeypatch.setattr(sandbox_workspace, "THUMBNAIL_SOURCE_LIMIT", 8) assert ( @@ -1694,7 +1764,7 @@ def test_an_image_declaring_more_pixels_than_it_holds_is_never_decoded(self, cap from app.services.sandbox_workspace import stored_thumbnail stored = StateBackend() - stored.write("/bomb.png", _png_declaring((8000, 8000))) + stored.write_bytes("/bomb.png", _png_declaring((8000, 8000))) with caplog.at_level(logging.WARNING): assert stored_thumbnail("/bomb.png", dict(stored.files["/bomb.png"])) is None @@ -1707,7 +1777,7 @@ def test_a_photograph_is_turned_upright_before_it_is_scaled(self): from app.services.sandbox_workspace import stored_thumbnail stored = StateBackend() - stored.write("/holiday.jpg", _rotated_jpeg()) + stored.write_bytes("/holiday.jpg", _rotated_jpeg()) uri = stored_thumbnail("/holiday.jpg", dict(stored.files["/holiday.jpg"])) @@ -1721,7 +1791,7 @@ def test_a_transparent_image_is_not_flattened_onto_black(self): from app.services.sandbox_workspace import stored_thumbnail stored = StateBackend() - stored.write("/logo.png", _transparent_png()) + stored.write_bytes("/logo.png", _transparent_png()) uri = stored_thumbnail("/logo.png", dict(stored.files["/logo.png"])) @@ -1736,7 +1806,7 @@ def test_a_png_that_is_not_one_is_logged_and_drawn_as_a_mark(self, caplog): from app.services.sandbox_workspace import stored_thumbnail stored = StateBackend() - stored.write("/chart.png", b"\x89PNG\r\n\x1a\n....") + stored.write_bytes("/chart.png", b"\x89PNG\r\n\x1a\n....") with caplog.at_level(logging.WARNING): assert stored_thumbnail("/chart.png", dict(stored.files["/chart.png"])) is None @@ -1757,7 +1827,7 @@ async def test_the_listing_carries_the_thumbnail(self, monkeypatch, mock_db_sess from app.repositories import agent as agent_repo stored = StateBackend() - stored.write("/chart.png", _png()) + stored.write_bytes("/chart.png", _png()) monkeypatch.setattr( workspace_repo, "list_for_reader", @@ -1918,29 +1988,124 @@ async def _resolve(self, ctx, connection_id): assert sorted(map(str, seen)) == sorted(map(str, [first, second])) -class TestContainerBackedWorkspaces: - """The paths that talk to something outside this process.""" +class _Sandboxes: + """Every sandbox the service asked a provider for, each a real directory with a real shell. - async def test_a_docker_workspace_labels_its_tenant_and_reattaches( - self, monkeypatch, mock_db_session - ): - """`tenant` is capacity accounting, and `reuse` is what makes a - conversation's workspace the same one next turn rather than a 409.""" - from pydantic_ai_backends import remote as remote_module + Stands in for `SandboxdWorkspace` and `DaytonaWorkspace` at the one place the + service builds them: a test reads what was asked of the provider - which + session, which runtime, whose key - and the commands the service runs reach + an actual `rm` in `root` rather than an assertion about a string. + """ - seen: dict[str, object] = {} + def __init__(self, root: Path) -> None: + self.root = root + self.made: list[dict[str, object]] = [] + self.commands: list[list[str]] = [] + self.destroyed: list[WorkspaceRef] = [] + self.failing: CommandResult | None = None + self.unreachable: Exception | None = None + self.attached: list[WorkspaceRef | None] = [] + """The ref each backend was asked for: `None` opens by name, a ref only attaches.""" + self.gone = False + """Whether the session a ref names was purged on the host.""" + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + from app.services import sandbox_workspace + + sandboxes = self + + class _Backend(LocalWorkspaceBackend): + def __init__(self, named: WorkspaceRef, *, attach_only: bool) -> None: + super().__init__(sandboxes.root) + self._named = named + self._attach_only = attach_only + self._opened = attach_only + + @property + def ref(self) -> WorkspaceRef | None: + # As a provider's backend: a ref it was given from the start, one + # it opens by name only after the first operation. + return self._named if self._opened else None + + async def working_dir(self) -> str: + if self._attach_only and sandboxes.gone: + raise WorkspaceUnavailableError(f"session {self._named.id!r} no longer exists") + self._opened = True + return await super().working_dir() + + async def run( + self, + command: WorkspaceCommand, + *, + shell: bool = False, + env: Mapping[str, str] | None = None, + timeout: float | None = None, + ) -> CommandResult: + assert not isinstance(command, str), "the service runs argv, never a shell line" + sandboxes.commands.append(list(command)) + if sandboxes.failing is not None: + return sandboxes.failing + return await super().run(command, shell=shell, env=env, timeout=timeout) + + class _Provider: + provider: str + name: str + + def __init__(self, **kwargs: object) -> None: + sandboxes.made.append(kwargs) + + def backend(self, ref: WorkspaceRef | None = None) -> _Backend: + sandboxes.attached.append(ref) + named = ref or WorkspaceRef(provider=self.provider, id=self.name) + return _Backend(named, attach_only=ref is not None) + + async def destroy(self, ref: WorkspaceRef) -> None: + if sandboxes.unreachable is not None: + raise sandboxes.unreachable + sandboxes.destroyed.append(ref) + + class _Sandboxd(_Provider): + def __init__(self, **kwargs: object) -> None: + super().__init__(**kwargs) + self.provider = str(kwargs["provider"]) + self.name = str(kwargs["session_name"]) + + class _Daytona(_Provider): + def __init__(self, **kwargs: object) -> None: + super().__init__(**kwargs) + self.provider = "daytona" + self.name = str(kwargs["sandbox_name"]) + + monkeypatch.setattr(sandbox_workspace, "SandboxdWorkspace", _Sandboxd) + monkeypatch.setattr(sandbox_workspace, "DaytonaWorkspace", _Daytona) + + +@pytest.fixture +def sandboxes(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> _Sandboxes: + installed = _Sandboxes(tmp_path) + installed.install(monkeypatch) + return installed + + +def _a_row_is_created(monkeypatch: pytest.MonkeyPatch) -> AsyncMock: + monkeypatch.setattr(workspace_repo, "get_by_key", AsyncMock(return_value=None)) + created = AsyncMock(return_value=_row(backend="service")) + monkeypatch.setattr(workspace_repo, "create", created) + return created - class _Sandbox: - def __init__(self, url, **kwargs): - seen.update(kwargs, url=url) - monkeypatch.setattr(remote_module, "RemoteSandbox", _Sandbox) +class TestContainerBackedWorkspaces: + """The paths that talk to something outside this process.""" + + async def test_a_container_workspace_is_the_session_named_by_its_scope( + self, monkeypatch, mock_db_session, sandboxes + ): + """The scope key names the session, so every run of a scope reaches the + same one - the first of two concurrent runs creates it and the other + attaches - and `tenant` is the service's capacity accounting.""" resolved = _resolved() _serve(monkeypatch, resolved) - row = _row(backend="service") - monkeypatch.setattr(workspace_repo, "get_by_key", AsyncMock(return_value=None)) - created = AsyncMock(return_value=row) - monkeypatch.setattr(workspace_repo, "create", created) + created = _a_row_is_created(monkeypatch) identity = _identity() workspace = await SandboxWorkspaceService(mock_db_session).open( @@ -1949,19 +2114,39 @@ def __init__(self, url, **kwargs): assert workspace is not None assert workspace.kind == "service" - assert seen["url"] == "http://sandboxd:8080" - assert seen["token"] == "service-token" - assert seen["tenant"] == str(identity.organization_id) - assert seen["reuse"] is True - assert seen["runtime"] == "python" - # The service is told which session belongs to this row, so deleting the - # conversation can purge the sandbox rather than wait for a TTL. The - # connection is recorded for the same reason: nothing later has the spec. - assert created.await_args.kwargs["session_id"] == workspace.scope_key + [made] = sandboxes.made + assert made["service_url"] == "http://sandboxd:8080" + assert made["token"] == "service-token" + assert made["tenant"] == str(identity.organization_id) + assert made["runtime"] == "python" + assert made["session_name"] == workspace.scope_key + # One provider name per connection, so a ref from one host is never + # read as a session on another. + assert made["provider"] == f"sandboxd:{resolved.row.id}" + # Opened by name: nothing has been opened for this scope yet. + assert sandboxes.attached == [None] + # The row records the connection: deleting the conversation purges the + # sandbox through it, and nothing later has the spec. assert created.await_args.kwargs["connection_id"] == resolved.row.id + async def test_what_the_agent_does_there_is_recorded( + self, monkeypatch, mock_db_session, sandboxes + ): + """#1061: the service's own log is a ring buffer gone on restart.""" + _serve(monkeypatch, _resolved()) + _a_row_is_created(monkeypatch) + + workspace = await SandboxWorkspaceService(mock_db_session).open( + _spec(backend="service"), ctx=_ctx(), identity=_identity() + ) + + assert workspace is not None + assert isinstance(workspace.workspace, RecordingWorkspace) + await workspace.workspace.write_text("notes.txt", "kept") + assert (sandboxes.root / "notes.txt").read_text() == "kept" + async def test_moving_an_agent_to_another_host_opens_a_new_workspace( - self, monkeypatch, mock_db_session + self, monkeypatch, mock_db_session, sandboxes ): """The row records which host holds the files, and every read trusts it. @@ -1974,17 +2159,7 @@ async def test_moving_an_agent_to_another_host_opens_a_new_workspace( `connection_id=None` means "the organization's default", so this is reached by marking a second connection default, without editing a spec. """ - from pydantic_ai_backends import remote as remote_module - - class _Sandbox: - def __init__(self, url, **kwargs): - pass - - monkeypatch.setattr(remote_module, "RemoteSandbox", _Sandbox) - monkeypatch.setattr(workspace_repo, "get_by_key", AsyncMock(return_value=None)) - monkeypatch.setattr( - workspace_repo, "create", AsyncMock(return_value=_row(backend="service")) - ) + _a_row_is_created(monkeypatch) identity = _identity() service = SandboxWorkspaceService(mock_db_session) @@ -2013,43 +2188,43 @@ async def test_a_scope_it_cannot_key_is_refused_before_a_host_is_resolved( identity=_identity(user_id=None), ) + async def test_a_connection_with_no_address_is_refused_rather_than_called( + self, monkeypatch, mock_db_session, sandboxes + ): + """Without an address the client would post the organization's token at + whatever a relative URL resolves to.""" + _serve(monkeypatch, _resolved(base_url=None)) + _a_row_is_created(monkeypatch) + + with pytest.raises(BadRequestError, match="no address"): + await SandboxWorkspaceService(mock_db_session).open( + _spec(backend="service"), ctx=_ctx(), identity=_identity() + ) + + assert sandboxes.made == [] + async def test_the_connections_runtime_is_used_when_the_spec_names_none( - self, monkeypatch, mock_db_session + self, monkeypatch, mock_db_session, sandboxes ): """Three levels, each answering a different question: what this agent needs, what this host prefers, and what exists at all.""" - from pydantic_ai_backends import remote as remote_module - - seen: dict[str, object] = {} - monkeypatch.setattr( - remote_module, "RemoteSandbox", lambda url, **kwargs: seen.update(kwargs) or object() - ) _serve(monkeypatch, _resolved(default_runtime="data-science")) - monkeypatch.setattr(workspace_repo, "get_by_key", AsyncMock(return_value=None)) - monkeypatch.setattr( - workspace_repo, "create", AsyncMock(return_value=_row(backend="service")) - ) + _a_row_is_created(monkeypatch) await SandboxWorkspaceService(mock_db_session).open( _spec(backend="service"), ctx=_ctx(), identity=_identity() ) - assert seen["runtime"] == "data-science" + assert sandboxes.made[0]["runtime"] == "data-science" async def test_a_container_workspace_carries_what_is_installed_in_it( - self, monkeypatch, mock_db_session + self, monkeypatch, mock_db_session, sandboxes ): """The run has to tell its model, and only the open knows which runtime it resolved to. Without it an agent writes a PDF extractor beside the `lit` that would have read the file.""" - from pydantic_ai_backends import remote as remote_module - - monkeypatch.setattr(remote_module, "RemoteSandbox", lambda url, **kwargs: object()) _serve(monkeypatch, _resolved(default_runtime="workbench")) - monkeypatch.setattr(workspace_repo, "get_by_key", AsyncMock(return_value=None)) - monkeypatch.setattr( - workspace_repo, "create", AsyncMock(return_value=_row(backend="service")) - ) + _a_row_is_created(monkeypatch) opened = await SandboxWorkspaceService(mock_db_session).open( _spec(backend="service"), ctx=_ctx(), identity=_identity() @@ -2060,18 +2235,12 @@ async def test_a_container_workspace_carries_what_is_installed_in_it( assert "liteparse" in opened.briefing async def test_a_runtime_this_deployment_does_not_ship_is_described_to_nobody( - self, monkeypatch, mock_db_session + self, monkeypatch, mock_db_session, sandboxes ): """A host can be started with an allowlist of its own, and inventing a package list for one of its images would be a prompt that lies.""" - from pydantic_ai_backends import remote as remote_module - - monkeypatch.setattr(remote_module, "RemoteSandbox", lambda url, **kwargs: object()) _serve(monkeypatch, _resolved(default_runtime="their-own-image")) - monkeypatch.setattr(workspace_repo, "get_by_key", AsyncMock(return_value=None)) - monkeypatch.setattr( - workspace_repo, "create", AsyncMock(return_value=_row(backend="service")) - ) + _a_row_is_created(monkeypatch) opened = await SandboxWorkspaceService(mock_db_session).open( _spec(backend="service"), ctx=_ctx(), identity=_identity() @@ -2081,43 +2250,30 @@ async def test_a_runtime_this_deployment_does_not_ship_is_described_to_nobody( assert opened.briefing is None async def test_a_daytona_connection_uses_the_organizations_own_key( - self, monkeypatch, mock_db_session + self, monkeypatch, mock_db_session, sandboxes ): """Daytona bills an account the *organization* owns, so the key comes from that organization's vault by way of its connection. The SDK's own `DAYTONA_API_KEY` fallback would put every tenant's sandboxes on whichever account the deployment happened to configure.""" - import pydantic_ai_backends as backends_module - - seen: dict[str, object] = {} - - class _Sandbox: - def __init__(self, api_key=None, sandbox_id=None): - seen.update(api_key=api_key, sandbox_id=sandbox_id) - - monkeypatch.setattr(backends_module, "DaytonaSandbox", _Sandbox, raising=False) + monkeypatch.setenv("DAYTONA_API_KEY", "the-deployments-own-key") _serve(monkeypatch, _resolved(kind="daytona", base_url=None, token="dtn-live-key")) - monkeypatch.setattr(workspace_repo, "get_by_key", AsyncMock(return_value=None)) - monkeypatch.setattr( - workspace_repo, "create", AsyncMock(return_value=_row(backend="service")) - ) + _a_row_is_created(monkeypatch) workspace = await SandboxWorkspaceService(mock_db_session).open( _spec(backend="service"), ctx=_ctx(), identity=_identity() ) assert workspace is not None - assert seen["api_key"] == "dtn-live-key" - assert seen["sandbox_id"] == workspace.scope_key + [made] = sandboxes.made + assert made["config"].api_key == "dtn-live-key" + assert made["sandbox_name"] == workspace.scope_key async def test_the_spec_may_name_a_connection_other_than_the_default( - self, monkeypatch, mock_db_session + self, monkeypatch, mock_db_session, sandboxes ): """Two hosts is the whole reason connections are rows. An agent that names one has to reach that one, not whichever is marked default.""" - from pydantic_ai_backends import remote as remote_module - - monkeypatch.setattr(remote_module, "RemoteSandbox", lambda url, **kwargs: object()) asked: list[UUID | None] = [] async def _resolve(self, ctx, connection_id): @@ -2125,10 +2281,7 @@ async def _resolve(self, ctx, connection_id): return _resolved() monkeypatch.setattr(SandboxConnectionService, "resolve", _resolve) - monkeypatch.setattr(workspace_repo, "get_by_key", AsyncMock(return_value=None)) - monkeypatch.setattr( - workspace_repo, "create", AsyncMock(return_value=_row(backend="service")) - ) + _a_row_is_created(monkeypatch) named = uuid4() await SandboxWorkspaceService(mock_db_session).open( @@ -2137,21 +2290,32 @@ async def _resolve(self, ctx, connection_id): assert asked == [named] - async def test_a_run_scoped_sandbox_is_released_when_the_run_ends( - self, monkeypatch, mock_db_session + @pytest.mark.parametrize("kind", ["docker", "daytona"]) + async def test_a_run_scoped_sandbox_is_destroyed_when_the_run_ends( + self, monkeypatch, mock_db_session, sandboxes, kind ): - from pydantic_ai_backends import remote as remote_module - - stopped: dict[str, object] = {} + """A container has `sandboxd`'s idle reaper behind it; a Daytona sandbox + has nothing, and is billed to the organization until it is deleted - so + the call has to land for both.""" + _serve(monkeypatch, _resolved(kind=kind)) + service = SandboxWorkspaceService(mock_db_session) - class _Sandbox: - def __init__(self, url, **kwargs): - pass + workspace = await service.open( + _spec(backend="service", session_scope="run"), ctx=_ctx(), identity=_identity() + ) + assert workspace is not None + await workspace.workspace.write_text("notes.txt", "this run's") + await service.close(workspace) - def stop(self, purge=False): - stopped["purge"] = purge + assert sandboxes.destroyed == [workspace.workspace.ref] + assert sandboxes.destroyed[0].id == workspace.scope_key - monkeypatch.setattr(remote_module, "RemoteSandbox", _Sandbox) + async def test_a_run_scoped_sandbox_nothing_touched_is_never_asked_for( + self, monkeypatch, mock_db_session, sandboxes + ): + """The session opens on the first operation, so a run that never used its + workspace has nothing on the host, and removing it would be a round trip - + or, on Daytona, a lookup - for nothing.""" _serve(monkeypatch, _resolved()) service = SandboxWorkspaceService(mock_db_session) @@ -2160,62 +2324,31 @@ def stop(self, purge=False): ) await service.close(workspace) - assert stopped == {"purge": True} + assert sandboxes.destroyed == [] async def test_a_conversation_scoped_sandbox_outlives_the_run( - self, monkeypatch, mock_db_session + self, monkeypatch, mock_db_session, sandboxes ): """The next turn is meant to find the files this one wrote.""" - from pydantic_ai_backends import remote as remote_module - - stopped: list[bool] = [] - - class _Sandbox: - def __init__(self, url, **kwargs): - pass - - def stop(self, purge=False): - stopped.append(purge) - - monkeypatch.setattr(remote_module, "RemoteSandbox", _Sandbox) _serve(monkeypatch, _resolved()) - monkeypatch.setattr(workspace_repo, "get_by_key", AsyncMock(return_value=None)) - monkeypatch.setattr( - workspace_repo, "create", AsyncMock(return_value=_row(backend="service")) - ) + _a_row_is_created(monkeypatch) service = SandboxWorkspaceService(mock_db_session) workspace = await service.open(_spec(backend="service"), ctx=_ctx(), identity=_identity()) await service.close(workspace) - assert stopped == [] + assert sandboxes.destroyed == [] async def test_a_runs_spills_are_pruned_off_a_workspace_that_outlives_it( - self, monkeypatch, mock_db_session + self, monkeypatch, mock_db_session, sandboxes ): """The container half of #803: a `conversation`/`user`/`agent`-scoped workspace keeps its filesystem across runs, so the spills this run wrote are deleted at close - by handle, so a concurrent run's spills survive - and the emptied run directories are offered to `rmdir`, deepest first. """ - from pydantic_ai_backends import remote as remote_module - - commands: list[str] = [] - - class _Sandbox: - def __init__(self, url, **kwargs): - pass - - def execute(self, command, timeout=None): - commands.append(command) - return SimpleNamespace(exit_code=0, output="") - - monkeypatch.setattr(remote_module, "RemoteSandbox", _Sandbox) _serve(monkeypatch, _resolved()) - monkeypatch.setattr(workspace_repo, "get_by_key", AsyncMock(return_value=None)) - monkeypatch.setattr( - workspace_repo, "create", AsyncMock(return_value=_row(backend="service")) - ) + _a_row_is_created(monkeypatch) service = SandboxWorkspaceService(mock_db_session) workspace = await service.open(_spec(backend="service"), ctx=_ctx(), identity=_identity()) @@ -2225,146 +2358,58 @@ def execute(self, command, timeout=None): ) await service.close(workspace) - [command] = commands - assert "rm -f -- /workspace/tool_output/run-1/call-1.0 tool_output/run-1/call-2.0" in ( - command - ) - assert "rmdir -- /workspace/tool_output/run-1" in command - assert command.index("/workspace/tool_output/run-1 ") < command.index( - "/workspace/tool_output " - ) - - async def test_a_parked_run_leaves_its_spills_on_the_container( - self, monkeypatch, mock_db_session - ): - from pydantic_ai_backends import remote as remote_module - - commands: list[str] = [] - - class _Sandbox: - def __init__(self, url, **kwargs): - pass - - def execute(self, command, timeout=None): - commands.append(command) - return SimpleNamespace(exit_code=0, output="") - - monkeypatch.setattr(remote_module, "RemoteSandbox", _Sandbox) - _serve(monkeypatch, _resolved()) - monkeypatch.setattr(workspace_repo, "get_by_key", AsyncMock(return_value=None)) - monkeypatch.setattr( - workspace_repo, "create", AsyncMock(return_value=_row(backend="service")) + removed, emptied = sandboxes.commands + assert removed == [ + "rm", + "-f", + "--", + "/workspace/tool_output/run-1/call-1.0", + "tool_output/run-1/call-2.0", + ] + assert emptied[:2] == ["rmdir", "--"] + assert emptied.index("/workspace/tool_output/run-1") < emptied.index( + "/workspace/tool_output" ) - service = SandboxWorkspaceService(mock_db_session) - - workspace = await service.open(_spec(backend="service"), ctx=_ctx(), identity=_identity()) - assert workspace is not None - workspace.spills.append("/workspace/tool_output/run-1/call-1.0") - await service.close(workspace, keep_spills=True) - assert commands == [] - - async def test_a_path_outside_the_reserved_prefix_is_never_deleted( - self, monkeypatch, mock_db_session + @pytest.mark.parametrize( + ("spill", "keep_spills"), + [ + # A parked run's history still names its spills. + ("/workspace/tool_output/run-1/call-1.0", True), + # Only the overflow store appends to the log, but the delete is a + # command - so every path is checked against the invariant that + # makes it safe. + ("/workspace/report.md", False), + # `PurePosixPath` does not resolve `..`, so this would pass an + # ancestor check while naming a file outside the spill directory. + ("/workspace/tool_output/../../etc/passwd", False), + (None, False), + ], + ids=["parked", "outside-the-prefix", "parent-traversal", "no-spills"], + ) + async def test_nothing_is_deleted_that_is_not_this_runs_spill( + self, monkeypatch, mock_db_session, sandboxes, spill, keep_spills ): - """Only the overflow store appends to the spill log, but the delete runs a - shell command - so every path is still checked against the one invariant - that makes it safe, and a log holding only foreign paths runs nothing.""" - from pydantic_ai_backends import remote as remote_module - - commands: list[str] = [] - - class _Sandbox: - def __init__(self, url, **kwargs): - pass - - def execute(self, command, timeout=None): - commands.append(command) - return SimpleNamespace(exit_code=0, output="") - - monkeypatch.setattr(remote_module, "RemoteSandbox", _Sandbox) _serve(monkeypatch, _resolved()) - monkeypatch.setattr(workspace_repo, "get_by_key", AsyncMock(return_value=None)) - monkeypatch.setattr( - workspace_repo, "create", AsyncMock(return_value=_row(backend="service")) - ) + _a_row_is_created(monkeypatch) service = SandboxWorkspaceService(mock_db_session) workspace = await service.open(_spec(backend="service"), ctx=_ctx(), identity=_identity()) assert workspace is not None - workspace.spills.append("/workspace/report.md") - await service.close(workspace) - - assert commands == [] - - async def test_a_workspace_with_no_spills_runs_no_command(self, monkeypatch, mock_db_session): - from pydantic_ai_backends import remote as remote_module - - commands: list[str] = [] - - class _Sandbox: - def __init__(self, url, **kwargs): - pass - - def execute(self, command, timeout=None): # pragma: no cover - must not run - commands.append(command) - return SimpleNamespace(exit_code=0, output="") - - monkeypatch.setattr(remote_module, "RemoteSandbox", _Sandbox) - _serve(monkeypatch, _resolved()) - monkeypatch.setattr(workspace_repo, "get_by_key", AsyncMock(return_value=None)) - monkeypatch.setattr( - workspace_repo, "create", AsyncMock(return_value=_row(backend="service")) - ) - service = SandboxWorkspaceService(mock_db_session) - - workspace = await service.open(_spec(backend="service"), ctx=_ctx(), identity=_identity()) - await service.close(workspace) - - assert commands == [] - - async def test_a_backend_without_execute_leaves_the_spills_for_the_host( - self, monkeypatch, mock_db_session - ): - """A backend that cannot run a command cannot delete a file either; close - must shrug rather than fail the run's `finally`.""" - from pydantic_ai_backends import remote as remote_module - - class _Sandbox: - def __init__(self, url, **kwargs): - pass - - monkeypatch.setattr(remote_module, "RemoteSandbox", _Sandbox) - _serve(monkeypatch, _resolved()) - monkeypatch.setattr(workspace_repo, "get_by_key", AsyncMock(return_value=None)) - monkeypatch.setattr( - workspace_repo, "create", AsyncMock(return_value=_row(backend="service")) - ) - service = SandboxWorkspaceService(mock_db_session) + if spill is not None: + workspace.spills.append(spill) + await service.close(workspace, keep_spills=keep_spills) - workspace = await service.open(_spec(backend="service"), ctx=_ctx(), identity=_identity()) - assert workspace is not None - workspace.spills.append("/workspace/tool_output/run-1/call-1.0") - await service.close(workspace) + assert sandboxes.commands == [] async def test_a_failed_prune_is_logged_rather_than_raised( - self, monkeypatch, mock_db_session, caplog + self, monkeypatch, mock_db_session, sandboxes, caplog ): - from pydantic_ai_backends import remote as remote_module - - class _Sandbox: - def __init__(self, url, **kwargs): - pass - - def execute(self, command, timeout=None): - return SimpleNamespace(exit_code=1, output="rm: read-only file system") - - monkeypatch.setattr(remote_module, "RemoteSandbox", _Sandbox) - _serve(monkeypatch, _resolved()) - monkeypatch.setattr(workspace_repo, "get_by_key", AsyncMock(return_value=None)) - monkeypatch.setattr( - workspace_repo, "create", AsyncMock(return_value=_row(backend="service")) + sandboxes.failing = CommandResult( + exit_code=1, stdout="", stderr="rm: read-only file system" ) + _serve(monkeypatch, _resolved()) + _a_row_is_created(monkeypatch) service = SandboxWorkspaceService(mock_db_session) workspace = await service.open(_spec(backend="service"), ctx=_ctx(), identity=_identity()) @@ -2378,111 +2423,70 @@ def execute(self, command, timeout=None): assert record.handles == 1 assert "read-only" in record.output - async def test_a_handle_with_a_parent_traversal_is_never_deleted( - self, monkeypatch, mock_db_session + async def test_the_prune_deletes_through_a_real_shell( + self, monkeypatch, mock_db_session, sandboxes, caplog ): - """`PurePosixPath` does not resolve `..`, so `tool_output/../x` would pass - an ancestor check while naming a file outside the spill directory; a - handle carrying one runs nothing.""" - from pydantic_ai_backends import remote as remote_module - - commands: list[str] = [] - - class _Sandbox: - def __init__(self, url, **kwargs): - pass - - def execute(self, command, timeout=None): # pragma: no cover - must not run - commands.append(command) - return SimpleNamespace(exit_code=0, output="") - - monkeypatch.setattr(remote_module, "RemoteSandbox", _Sandbox) + """What a real `rm` and `rmdir` do with the argv, not what a fake accepts: + the spilled files are gone, the emptied directories are gone, and nothing + is reported as failed.""" _serve(monkeypatch, _resolved()) - monkeypatch.setattr(workspace_repo, "get_by_key", AsyncMock(return_value=None)) - monkeypatch.setattr( - workspace_repo, "create", AsyncMock(return_value=_row(backend="service")) - ) + _a_row_is_created(monkeypatch) service = SandboxWorkspaceService(mock_db_session) workspace = await service.open(_spec(backend="service"), ctx=_ctx(), identity=_identity()) assert workspace is not None - workspace.spills.append("/workspace/tool_output/../../etc/passwd") - await service.close(workspace) - - assert commands == [] - - async def test_the_prune_command_deletes_through_a_real_shell( - self, monkeypatch, mock_db_session, tmp_path, caplog - ): - """The command is what a real `sh -c` runs, not what a mock accepts: the - spilled files are gone, the emptied directories are gone, and nothing is - reported as failed.""" - from pydantic_ai_backends import remote as remote_module + spill_dir = sandboxes.root / "tool_output" / "run-1" + spill_dir.mkdir(parents=True) + (spill_dir / "call-1.0").write_text("payload") + workspace.spills.append("tool_output/run-1/call-1.0") - class _Sandbox: - def __init__(self, url, **kwargs): - pass + with caplog.at_level(logging.WARNING): + await service.close(workspace) - def execute(self, command, timeout=None): - run = subprocess.run(["/bin/sh", "-c", command], capture_output=True, text=True) - return SimpleNamespace(exit_code=run.returncode, output=run.stdout + run.stderr) + assert not (sandboxes.root / "tool_output").exists() + assert not [r for r in caplog.records if r.message == "workspace_spill_prune_failed"] - monkeypatch.setattr(remote_module, "RemoteSandbox", _Sandbox) + async def test_a_directory_another_run_still_uses_is_left_in_place( + self, monkeypatch, mock_db_session, sandboxes, caplog + ): + """`rmdir` refusing a directory that still holds a concurrent run's spill + is the right answer, and not the failure the warning is for.""" _serve(monkeypatch, _resolved()) - monkeypatch.setattr(workspace_repo, "get_by_key", AsyncMock(return_value=None)) - monkeypatch.setattr( - workspace_repo, "create", AsyncMock(return_value=_row(backend="service")) - ) + _a_row_is_created(monkeypatch) service = SandboxWorkspaceService(mock_db_session) workspace = await service.open(_spec(backend="service"), ctx=_ctx(), identity=_identity()) assert workspace is not None - spill_dir = tmp_path / "tool_output" / "run-1" - spill_dir.mkdir(parents=True) - spill = spill_dir / "call-1.0" - spill.write_text("payload") - workspace.spills.append(str(spill)) + (sandboxes.root / "tool_output" / "run-1").mkdir(parents=True) + (sandboxes.root / "tool_output" / "run-1" / "call-1.0").write_text("mine") + (sandboxes.root / "tool_output" / "run-2").mkdir() + (sandboxes.root / "tool_output" / "run-2" / "call-1.0").write_text("theirs") + workspace.spills.append("tool_output/run-1/call-1.0") with caplog.at_level(logging.WARNING): await service.close(workspace) - assert not spill.exists() - assert not spill_dir.exists() - assert not (tmp_path / "tool_output").exists() + assert not (sandboxes.root / "tool_output" / "run-1").exists() + assert (sandboxes.root / "tool_output" / "run-2" / "call-1.0").read_text() == "theirs" assert not [r for r in caplog.records if r.message == "workspace_spill_prune_failed"] @pytest.mark.skipif(os.geteuid() == 0, reason="root is never refused the rm") async def test_a_rm_the_shell_refuses_reaches_the_prune_warning( - self, monkeypatch, mock_db_session, tmp_path, caplog + self, monkeypatch, mock_db_session, sandboxes, caplog ): - """The regression the mocked failure test cannot catch: the command's own - exit status must carry a refused `rm` out of the shell, where a trailing - cleanup (`; true`) would have reported the failed prune as success.""" - from pydantic_ai_backends import remote as remote_module - - class _Sandbox: - def __init__(self, url, **kwargs): - pass - - def execute(self, command, timeout=None): - run = subprocess.run(["/bin/sh", "-c", command], capture_output=True, text=True) - return SimpleNamespace(exit_code=run.returncode, output=run.stdout + run.stderr) - - monkeypatch.setattr(remote_module, "RemoteSandbox", _Sandbox) + """The regression a fake failure cannot catch: the exit status of the real + `rm` has to reach the warning.""" _serve(monkeypatch, _resolved()) - monkeypatch.setattr(workspace_repo, "get_by_key", AsyncMock(return_value=None)) - monkeypatch.setattr( - workspace_repo, "create", AsyncMock(return_value=_row(backend="service")) - ) + _a_row_is_created(monkeypatch) service = SandboxWorkspaceService(mock_db_session) workspace = await service.open(_spec(backend="service"), ctx=_ctx(), identity=_identity()) assert workspace is not None - locked = tmp_path / "tool_output" / "run-1" + locked = sandboxes.root / "tool_output" / "run-1" locked.mkdir(parents=True) spill = locked / "call-1.0" spill.write_text("payload") - workspace.spills.append(str(spill)) + workspace.spills.append("tool_output/run-1/call-1.0") locked.chmod(0o555) try: @@ -2496,96 +2500,198 @@ def execute(self, command, timeout=None): assert record.handles == 1 assert "Permission denied" in record.output - async def test_a_backend_that_cannot_be_stopped_is_left_alone( - self, monkeypatch, mock_db_session - ): - """A backend with no `stop` at all, and `close` must not care. - Not Daytona - that one does expose `stop`, which is what the test below - is about. This is the branch for a `state`-shaped object arriving here. - """ - import pydantic_ai_backends as backends_module +class TestASessionAcrossRuns: + """A session is opened once, by name, and only ever attached to after. - class _Sandbox: - def __init__(self, api_key=None, sandbox_id=None): - pass + So a session purged on the host - its files swept by a TTL, the host + rebuilt - is reported to the run that finds it gone, rather than replaced + by an empty one under the same name that the agent would carry on in. + """ - monkeypatch.setattr(backends_module, "DaytonaSandbox", _Sandbox, raising=False) - _serve(monkeypatch, _resolved(kind="daytona", base_url=None)) + def _row_with(self, monkeypatch, mock_db_session, session_id: str | None) -> AsyncMock: + row = _row(backend="service", session_id=session_id, connection_id=uuid4()) + monkeypatch.setattr(workspace_repo, "get_by_key", AsyncMock(return_value=row)) + monkeypatch.setattr(workspace_repo, "touch", AsyncMock(return_value=row)) + mock_db_session.get = AsyncMock(return_value=row) + recorded = AsyncMock(return_value=row) + monkeypatch.setattr(workspace_repo, "record_session", recorded) + return recorded + + async def test_the_session_a_run_opened_is_recorded_for_the_next( + self, monkeypatch, mock_db_session, sandboxes + ): + _serve(monkeypatch, _resolved()) + recorded = self._row_with(monkeypatch, mock_db_session, None) service = SandboxWorkspaceService(mock_db_session) - workspace = await service.open( - _spec(backend="service", session_scope="run"), ctx=_ctx(), identity=_identity() - ) + workspace = await service.open(_spec(backend="service"), ctx=_ctx(), identity=_identity()) + assert workspace is not None + await workspace.workspace.write_text("notes.txt", "kept") await service.close(workspace) - async def test_a_daytona_sandbox_is_stopped_with_the_signature_it_has( - self, monkeypatch, mock_db_session + assert recorded.await_args.kwargs["session_id"] == workspace.scope_key + + async def test_a_run_that_opened_nothing_records_nothing( + self, monkeypatch, mock_db_session, sandboxes ): - """`DaytonaSandbox.stop()` takes no arguments and deletes the sandbox. + """Nothing is on the host yet, so there is nothing for the next run to + insist on attaching to.""" + _serve(monkeypatch, _resolved()) + recorded = self._row_with(monkeypatch, mock_db_session, None) + service = SandboxWorkspaceService(mock_db_session) - Calling it as `stop(purge=True)` raised a `TypeError` that `close` - swallowed as `workspace_close_failed` - so the one backend with no idle - reaper behind it was never released, once per run, on the organization's - own Daytona account. The old fake here exposed no `stop` at all and so - proved the early return rather than this. - """ - import pydantic_ai_backends as backends_module + workspace = await service.open(_spec(backend="service"), ctx=_ctx(), identity=_identity()) + await service.close(workspace) - stopped: list[str] = [] + recorded.assert_not_awaited() - class _Sandbox: - def __init__(self, api_key=None, sandbox_id=None): - self._id = sandbox_id + async def test_a_recorded_session_is_attached_to_rather_than_opened( + self, monkeypatch, mock_db_session, sandboxes + ): + resolved = _resolved() + _serve(monkeypatch, resolved) + self._row_with(monkeypatch, mock_db_session, "xc-recorded") - def stop(self, purge: bool = False) -> None: - stopped.append(f"deleted purge={purge}") + await SandboxWorkspaceService(mock_db_session).open( + _spec(backend="service"), ctx=_ctx(), identity=_identity() + ) - monkeypatch.setattr(backends_module, "DaytonaSandbox", _Sandbox, raising=False) - _serve(monkeypatch, _resolved(kind="daytona", base_url=None)) - service = SandboxWorkspaceService(mock_db_session) + assert sandboxes.attached == [ + WorkspaceRef(provider=f"sandboxd:{resolved.row.id}", id="xc-recorded") + ] - workspace = await service.open( - _spec(backend="service", session_scope="run"), ctx=_ctx(), identity=_identity() + async def test_a_purged_session_is_reported_rather_than_replaced( + self, monkeypatch, mock_db_session, sandboxes + ): + sandboxes.gone = True + _serve(monkeypatch, _resolved()) + self._row_with(monkeypatch, mock_db_session, "xc-recorded") + + workspace = await SandboxWorkspaceService(mock_db_session).open( + _spec(backend="service"), ctx=_ctx(), identity=_identity() ) - await service.close(workspace) - assert stopped == ["deleted purge=True"] + assert workspace is not None + with pytest.raises(WorkspaceUnavailableError): + await workspace.workspace.write_text("notes.txt", "into an empty directory") + assert not (sandboxes.root / "notes.txt").exists() - async def test_a_container_session_is_still_purged_when_it_is_stopped( - self, monkeypatch, mock_db_session + async def test_the_model_is_told_its_files_are_gone( + self, monkeypatch, mock_db_session, sandboxes ): - """The other side of the same call: `RemoteSandbox.stop` does take - `purge`, and a run-scoped container's files go with its session.""" - from pydantic_ai_backends import remote as remote_module + """What the run reports: the console answers the tool call with the loss, + so the agent says so instead of working on as if the files were there.""" + sandboxes.gone = True + _serve(monkeypatch, _resolved()) + self._row_with(monkeypatch, mock_db_session, "xc-recorded") + workspace = await SandboxWorkspaceService(mock_db_session).open( + _spec(backend="service"), ctx=_ctx(), identity=_identity() + ) + assert workspace is not None + answered: list[str] = [] - stopped: list[bool] = [] + def model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: + returns = [p for p in messages[-1].parts if isinstance(p, ToolReturnPart)] + if returns: + answered.append(str(returns[0].content)) + return ModelResponse(parts=[TextPart("done")]) + return ModelResponse(parts=[ToolCallPart("ls", {"path": "."})]) - class _Sandbox: - def __init__(self, url, **kwargs): - pass + await Agent( + FunctionModel(model), capabilities=[build_workspace(include_execute=False)] + ).run("what is there?", workspace=workspace.workspace) - def stop(self, purge=False): - stopped.append(purge) + [listing] = answered + assert "no longer exists" in listing - monkeypatch.setattr(remote_module, "RemoteSandbox", _Sandbox) + async def test_a_conversation_deleted_mid_run_records_no_session( + self, monkeypatch, mock_db_session, sandboxes + ): + """Its row went with it; there is nothing left to record a session on.""" _serve(monkeypatch, _resolved()) + recorded = self._row_with(monkeypatch, mock_db_session, None) + mock_db_session.get = AsyncMock(return_value=None) service = SandboxWorkspaceService(mock_db_session) + workspace = await service.open(_spec(backend="service"), ctx=_ctx(), identity=_identity()) + assert workspace is not None + await workspace.workspace.write_text("notes.txt", "kept") - workspace = await service.open( - _spec(backend="service", session_scope="run"), ctx=_ctx(), identity=_identity() - ) await service.close(workspace) - assert stopped == [True] + recorded.assert_not_awaited() + + async def test_a_lost_session_is_forgotten_so_the_next_run_starts_afresh( + self, monkeypatch, mock_db_session, sandboxes, caplog + ): + """The run that found it gone has reported it; the turn after should not + hit it again. Nothing is pruned on a host that holds nothing.""" + sandboxes.gone = True + _serve(monkeypatch, _resolved()) + recorded = self._row_with(monkeypatch, mock_db_session, "xc-recorded") + service = SandboxWorkspaceService(mock_db_session) + workspace = await service.open(_spec(backend="service"), ctx=_ctx(), identity=_identity()) + assert workspace is not None + workspace.spills.append("tool_output/run-1/call-1.0") + with pytest.raises(WorkspaceUnavailableError): + await workspace.workspace.read_text("notes.txt") + + with caplog.at_level(logging.WARNING): + await service.close(workspace) + + assert recorded.await_args.kwargs["session_id"] is None + assert sandboxes.commands == [] + assert sandboxes.destroyed == [] + assert [r for r in caplog.records if r.message == "workspace_session_lost"] + + async def test_a_lost_daytona_sandbox_is_deleted_so_its_name_opens_afresh( + self, monkeypatch, mock_db_session, sandboxes + ): + """A sandbox in `error` still holds the scope's name; left there, every + later run would attach to it and be refused again.""" + sandboxes.gone = True + _serve(monkeypatch, _resolved(kind="daytona", base_url=None, token="dtn-live-key")) + self._row_with(monkeypatch, mock_db_session, "dt-recorded") + service = SandboxWorkspaceService(mock_db_session) + workspace = await service.open(_spec(backend="service"), ctx=_ctx(), identity=_identity()) + assert workspace is not None + with pytest.raises(WorkspaceUnavailableError): + await workspace.workspace.read_text("notes.txt") + + await service.close(workspace) + + assert sandboxes.destroyed == [WorkspaceRef(provider="daytona", id="dt-recorded")] + + async def test_a_lost_daytona_sandbox_that_cannot_be_deleted_is_logged( + self, monkeypatch, mock_db_session, sandboxes, caplog + ): + sandboxes.gone = True + sandboxes.unreachable = RuntimeError("daytona is down") + _serve(monkeypatch, _resolved(kind="daytona", base_url=None, token="dtn-live-key")) + recorded = self._row_with(monkeypatch, mock_db_session, "dt-recorded") + service = SandboxWorkspaceService(mock_db_session) + workspace = await service.open(_spec(backend="service"), ctx=_ctx(), identity=_identity()) + assert workspace is not None + with pytest.raises(WorkspaceUnavailableError): + await workspace.workspace.read_text("notes.txt") + + with caplog.at_level(logging.WARNING): + await service.close(workspace) + + assert recorded.await_args.kwargs["session_id"] is None + assert [r for r in caplog.records if r.message == "workspace_lost_sandbox_not_deleted"] + + +def _conversation_holds(monkeypatch: pytest.MonkeyPatch, *rows: object) -> AsyncMock: + monkeypatch.setattr(workspace_repo, "list_for_conversation", AsyncMock(return_value=list(rows))) + deleted = AsyncMock() + monkeypatch.setattr(workspace_repo, "delete", deleted) + return deleted class TestDeletingAConversation: async def test_every_workspace_of_the_conversation_goes(self, monkeypatch, mock_db_session): - rows = [_row(), _row()] - monkeypatch.setattr(workspace_repo, "list_for_conversation", AsyncMock(return_value=rows)) - deleted = AsyncMock() - monkeypatch.setattr(workspace_repo, "delete", deleted) + deleted = _conversation_holds(monkeypatch, _row(), _row()) count = await SandboxWorkspaceService(mock_db_session).purge_for_conversation( _ctx(), conversation_id=uuid4() @@ -2595,58 +2701,35 @@ async def test_every_workspace_of_the_conversation_goes(self, monkeypatch, mock_ assert deleted.await_count == 2 async def test_a_container_workspace_is_purged_on_the_service_too( - self, monkeypatch, mock_db_session + self, monkeypatch, mock_db_session, sandboxes ): """The row would cascade away; the container would sit on the host.""" - from pydantic_ai_backends import remote as remote_module - - purged: list[str] = [] - - class _Sandbox: - def __init__(self, url, **kwargs): - purged.append(kwargs["session_id"]) - - def stop(self, purge=False): - pass - - monkeypatch.setattr(remote_module, "RemoteSandbox", _Sandbox) - _serve(monkeypatch, _resolved()) - monkeypatch.setattr( - workspace_repo, - "list_for_conversation", - AsyncMock( - return_value=[_row(backend="service", session_id="dc-1", connection_id=uuid4())] - ), + resolved = _resolved() + _serve(monkeypatch, resolved) + _conversation_holds( + monkeypatch, _row(backend="service", session_id="dc-1", connection_id=uuid4()) ) - monkeypatch.setattr(workspace_repo, "delete", AsyncMock()) await SandboxWorkspaceService(mock_db_session).purge_for_conversation( _ctx(), conversation_id=uuid4() ) - assert purged == ["dc-1"] + # By the session the row recorded, on the connection's own provider name. + assert sandboxes.destroyed == [ + WorkspaceRef(provider=f"sandboxd:{resolved.row.id}", id="dc-1") + ] - async def test_a_service_that_is_down_does_not_stop_the_deletion( - self, monkeypatch, mock_db_session + async def test_a_daytona_sandbox_is_deleted_rather_than_left_running( + self, monkeypatch, mock_db_session, sandboxes ): - """The workspace TTL is the net under exactly this.""" - from pydantic_ai_backends import remote as remote_module - - class _Sandbox: - def __init__(self, url, **kwargs): - raise RuntimeError("connection refused") + """It is billed to the organization and nothing else will end it. - monkeypatch.setattr(remote_module, "RemoteSandbox", _Sandbox) - _serve(monkeypatch, _resolved()) - monkeypatch.setattr( - workspace_repo, - "list_for_conversation", - AsyncMock( - return_value=[_row(backend="service", session_id="dc-1", connection_id=uuid4())] - ), + A container has `sandboxd`'s TTL under it; a cloud sandbox has only this. + """ + _serve(monkeypatch, _resolved(kind="daytona", base_url=None, token="dtn-live-key")) + deleted = _conversation_holds( + monkeypatch, _row(backend="service", session_id="dt-1", connection_id=uuid4()) ) - deleted = AsyncMock() - monkeypatch.setattr(workspace_repo, "delete", deleted) count = await SandboxWorkspaceService(mock_db_session).purge_for_conversation( _ctx(), conversation_id=uuid4() @@ -2654,22 +2737,22 @@ def __init__(self, url, **kwargs): assert count == 1 deleted.assert_awaited_once() + # The organization's own key, and the sandbox the row recorded. + assert sandboxes.made[0]["config"].api_key == "dtn-live-key" + assert sandboxes.destroyed == [WorkspaceRef(provider="daytona", id="dt-1")] - async def test_a_connection_deleted_since_does_not_stop_the_deletion( - self, monkeypatch, mock_db_session + @pytest.mark.parametrize("kind", ["docker", "daytona"]) + async def test_a_host_that_is_down_does_not_stop_the_deletion( + self, monkeypatch, mock_db_session, sandboxes, kind ): - """`SET NULL` makes this reachable: the host was forgotten, the row that - records what an agent did on it was not. Deleting the chat still works.""" - _serve(monkeypatch, BadRequestError(message="that connection no longer exists")) - monkeypatch.setattr( - workspace_repo, - "list_for_conversation", - AsyncMock( - return_value=[_row(backend="service", session_id="dc-1", connection_id=uuid4())] - ), + """A provider having a bad day must not be the reason somebody cannot + delete their own conversation; the workspace TTL is the net under a + container, and the warning is what is left for a cloud sandbox.""" + sandboxes.unreachable = RuntimeError("connection refused") + _serve(monkeypatch, _resolved(kind=kind)) + deleted = _conversation_holds( + monkeypatch, _row(backend="service", session_id="dc-1", connection_id=uuid4()) ) - deleted = AsyncMock() - monkeypatch.setattr(workspace_repo, "delete", deleted) count = await SandboxWorkspaceService(mock_db_session).purge_for_conversation( _ctx(), conversation_id=uuid4() @@ -2678,42 +2761,15 @@ async def test_a_connection_deleted_since_does_not_stop_the_deletion( assert count == 1 deleted.assert_awaited_once() - async def test_a_daytona_sandbox_is_deleted_rather_than_left_running( + async def test_a_connection_deleted_since_does_not_stop_the_deletion( self, monkeypatch, mock_db_session ): - """It is billed to the organization and nothing else will end it. - - This used to assert the opposite - that "nothing is called" - on the - grounds that Daytona "keeps no session of ours to purge". The first half - was right and the conclusion did not follow: the reason not to reach for - `RemoteSandbox` here is that it would send a Daytona key to a `sandboxd` - that does not exist, which is an argument for calling `DaytonaSandbox` - instead, not for calling nothing. A container has `sandboxd`'s TTL under - it; a cloud sandbox has only this. - """ - import pydantic_ai_backends as backends_module - - deleted_sandboxes: list[tuple[str, str]] = [] - - class _Sandbox: - def __init__(self, api_key=None, sandbox_id=None): - self._key = api_key - self._id = sandbox_id - - def stop(self, purge: bool = False) -> None: - deleted_sandboxes.append((self._key, self._id)) - - monkeypatch.setattr(backends_module, "DaytonaSandbox", _Sandbox, raising=False) - _serve(monkeypatch, _resolved(kind="daytona", base_url=None, token="dtn-live-key")) - monkeypatch.setattr( - workspace_repo, - "list_for_conversation", - AsyncMock( - return_value=[_row(backend="service", session_id="dt-1", connection_id=uuid4())] - ), + """`SET NULL` makes this reachable: the host was forgotten, the row that + records what an agent did on it was not. Deleting the chat still works.""" + _serve(monkeypatch, BadRequestError(message="that connection no longer exists")) + deleted = _conversation_holds( + monkeypatch, _row(backend="service", session_id="dc-1", connection_id=uuid4()) ) - deleted = AsyncMock() - monkeypatch.setattr(workspace_repo, "delete", deleted) count = await SandboxWorkspaceService(mock_db_session).purge_for_conversation( _ctx(), conversation_id=uuid4() @@ -2721,31 +2777,17 @@ def stop(self, purge: bool = False) -> None: assert count == 1 deleted.assert_awaited_once() - # The organization's own key, and the session the row recorded. - assert deleted_sandboxes == [("dtn-live-key", "dt-1")] async def test_a_container_connection_with_no_address_is_not_called( - self, monkeypatch, mock_db_session + self, monkeypatch, mock_db_session, sandboxes ): - """`RemoteSandbox("")` would post the organization's service token at - whatever an empty base URL resolves to, so a row with no address is left - alone and the workspace row still goes.""" - from pydantic_ai_backends import remote as remote_module - - def _explode(*_args: object, **_kwargs: object) -> None: - raise AssertionError("no address, so nothing should have been built") - - monkeypatch.setattr(remote_module, "RemoteSandbox", _explode) + """A client with no address would post the organization's service token + at whatever an empty base URL resolves to, so a row with no address is + left alone and the workspace row still goes.""" _serve(monkeypatch, _resolved(base_url=None)) - monkeypatch.setattr( - workspace_repo, - "list_for_conversation", - AsyncMock( - return_value=[_row(backend="service", session_id="dc-1", connection_id=uuid4())] - ), + deleted = _conversation_holds( + monkeypatch, _row(backend="service", session_id="dc-1", connection_id=uuid4()) ) - deleted = AsyncMock() - monkeypatch.setattr(workspace_repo, "delete", deleted) count = await SandboxWorkspaceService(mock_db_session).purge_for_conversation( _ctx(), conversation_id=uuid4() @@ -2753,45 +2795,12 @@ def _explode(*_args: object, **_kwargs: object) -> None: assert count == 1 deleted.assert_awaited_once() + assert sandboxes.made == [] - async def test_a_daytona_host_that_is_down_still_lets_the_chat_go( - self, monkeypatch, mock_db_session + async def test_a_state_workspace_needs_no_service_call( + self, monkeypatch, mock_db_session, sandboxes ): - """Same net as the container path: a cloud provider having a bad day must - not be the reason somebody cannot delete their own conversation.""" - import pydantic_ai_backends as backends_module - - class _Sandbox: - def __init__(self, api_key=None, sandbox_id=None): - pass - - def stop(self, purge: bool = False) -> None: - raise RuntimeError("daytona unreachable") - - monkeypatch.setattr(backends_module, "DaytonaSandbox", _Sandbox, raising=False) - _serve(monkeypatch, _resolved(kind="daytona", base_url=None)) - monkeypatch.setattr( - workspace_repo, - "list_for_conversation", - AsyncMock( - return_value=[_row(backend="service", session_id="dt-1", connection_id=uuid4())] - ), - ) - deleted = AsyncMock() - monkeypatch.setattr(workspace_repo, "delete", deleted) - - count = await SandboxWorkspaceService(mock_db_session).purge_for_conversation( - _ctx(), conversation_id=uuid4() - ) - - assert count == 1 - deleted.assert_awaited_once() - - async def test_a_state_workspace_needs_no_service_call(self, monkeypatch, mock_db_session): - monkeypatch.setattr( - workspace_repo, "list_for_conversation", AsyncMock(return_value=[_row()]) - ) - monkeypatch.setattr(workspace_repo, "delete", AsyncMock()) + _conversation_holds(monkeypatch, _row()) assert ( await SandboxWorkspaceService(mock_db_session).purge_for_conversation( @@ -2799,19 +2808,15 @@ async def test_a_state_workspace_needs_no_service_call(self, monkeypatch, mock_d ) == 1 ) + assert sandboxes.made == [] async def test_a_workspace_whose_host_was_forgotten_is_still_deleted( - self, monkeypatch, mock_db_session + self, monkeypatch, mock_db_session, sandboxes ): """No `connection_id` left means nothing to ask, not a failure.""" - monkeypatch.setattr( - workspace_repo, - "list_for_conversation", - AsyncMock( - return_value=[_row(backend="service", session_id="dc-1", connection_id=None)] - ), + _conversation_holds( + monkeypatch, _row(backend="service", session_id="dc-1", connection_id=None) ) - monkeypatch.setattr(workspace_repo, "delete", AsyncMock()) assert ( await SandboxWorkspaceService(mock_db_session).purge_for_conversation( @@ -2819,6 +2824,7 @@ async def test_a_workspace_whose_host_was_forgotten_is_still_deleted( ) == 1 ) + assert sandboxes.made == [] class TestShowingTheFilesToAPerson: @@ -2884,7 +2890,7 @@ async def test_a_conversation_with_no_workspace_lists_nothing( async def test_a_state_workspace_lists_what_it_holds(self, monkeypatch, mock_db_session): stored = StateBackend() - stored.write("/uploads/report.csv", "month,total") + stored.write_bytes("/uploads/report.csv", b"month,total") monkeypatch.setattr( workspace_repo, "list_for_conversation", @@ -2902,7 +2908,7 @@ async def test_a_state_workspace_lists_what_it_holds(self, monkeypatch, mock_db_ async def test_a_state_file_is_read_back(self, monkeypatch, mock_db_session): stored = StateBackend() - stored.write("/uploads/report.csv", "month,total") + stored.write_bytes("/uploads/report.csv", b"month,total") monkeypatch.setattr( workspace_repo, "list_for_conversation", @@ -3382,7 +3388,7 @@ async def test_the_files_of_one_workspace_come_back_with_its_row( self, monkeypatch, mock_db_session ): stored = StateBackend() - stored.write("/uploads/report.csv", "month,total") + stored.write_bytes("/uploads/report.csv", b"month,total") row = _row(files=dict(stored.files)) monkeypatch.setattr(workspace_repo, "get", AsyncMock(return_value=row)) @@ -3403,7 +3409,7 @@ async def test_another_organizations_workspace_reads_as_missing( async def test_one_file_is_read_out_of_it(self, monkeypatch, mock_db_session): stored = StateBackend() - stored.write("/uploads/report.csv", "month,total") + stored.write_bytes("/uploads/report.csv", b"month,total") row = _row(files=dict(stored.files)) monkeypatch.setattr(workspace_repo, "get", AsyncMock(return_value=row)) @@ -3477,8 +3483,8 @@ async def test_a_stored_workspace_is_counted_without_being_asked( from app.repositories import agent as agent_repo stored = StateBackend() - stored.write("/report.csv", "a,b\n1,2") - stored.write("/notes.md", "hello") + stored.write_bytes("/report.csv", b"a,b\n1,2") + stored.write_bytes("/notes.md", b"hello") row = _row(files=dict(stored.files)) monkeypatch.setattr(workspace_repo, "list_for_reader", AsyncMock(return_value=[row])) monkeypatch.setattr(agent_repo, "get_many", AsyncMock(return_value={})) @@ -4123,9 +4129,9 @@ class TestWhatTheBrowserDoesNotShow: async def test_a_conversations_files_are_the_conversations(self, monkeypatch, mock_db_session): stored = StateBackend() - stored.write("/uploads/book.pdf", "a") - stored.write("/workspace/skills/code-review/SKILL.md", "b") - stored.write("/report.csv", "c") + stored.write_bytes("/uploads/book.pdf", b"a") + stored.write_bytes("/workspace/skills/code-review/SKILL.md", b"b") + stored.write_bytes("/report.csv", b"c") row = _row(files=dict(stored.files)) monkeypatch.setattr(workspace_repo, "list_for_conversation", AsyncMock(return_value=[row])) @@ -4148,8 +4154,8 @@ async def test_a_container_spelling_a_skill_relatively_is_left_out_too( The browser has to drop both spellings, and a workspace written before the move holds the second one for real.""" stored = StateBackend() - stored.write("skills/code-review/SKILL.md", "a") - stored.write("/report.csv", "b") + stored.write_bytes("skills/code-review/SKILL.md", b"a") + stored.write_bytes("/report.csv", b"b") row = _row(files=dict(stored.files)) monkeypatch.setattr(workspace_repo, "list_for_conversation", AsyncMock(return_value=[row])) @@ -4177,8 +4183,8 @@ async def test_the_flat_view_leaves_them_out_too(self, monkeypatch, mock_db_sess from app.repositories import agent as agent_repo stored = StateBackend() - stored.write("/workspace/skills/code-review/checklist.md", "a") - stored.write("/summary.md", "b") + stored.write_bytes("/workspace/skills/code-review/checklist.md", b"a") + stored.write_bytes("/summary.md", b"b") row = _row(files=dict(stored.files)) monkeypatch.setattr(workspace_repo, "list_for_reader", AsyncMock(return_value=[row])) monkeypatch.setattr(agent_repo, "get_many", AsyncMock(return_value={})) @@ -4195,9 +4201,9 @@ async def test_they_are_not_counted_against_a_workspace_either( from app.repositories import agent as agent_repo stored = StateBackend() - stored.write("/workspace/skills/code-review/SKILL.md", "a") - stored.write("/workspace/skills/code-review/checklist.md", "b") - stored.write("/summary.md", "c") + stored.write_bytes("/workspace/skills/code-review/SKILL.md", b"a") + stored.write_bytes("/workspace/skills/code-review/checklist.md", b"b") + stored.write_bytes("/summary.md", b"c") row = _row(files=dict(stored.files)) monkeypatch.setattr(workspace_repo, "list_for_reader", AsyncMock(return_value=[row])) monkeypatch.setattr(agent_repo, "get_many", AsyncMock(return_value={})) @@ -4220,8 +4226,8 @@ class TestAListingCarriesWhatEachTileDraws: @staticmethod def _stored() -> dict[str, object]: stored = StateBackend() - stored.write("/uploads/sales.csv", "region,value\nEU,41200\n") - stored.write("/out/chart.png", _png()) + stored.write_bytes("/uploads/sales.csv", b"region,value\nEU,41200\n") + stored.write_bytes("/out/chart.png", _png()) return dict(stored.files) async def test_a_conversation_listing_previews_text_and_draws_images( diff --git a/backend/tests/test_security_marker.py b/backend/tests/test_security_marker.py index 081c7a762..31d8eccad 100644 --- a/backend/tests/test_security_marker.py +++ b/backend/tests/test_security_marker.py @@ -109,7 +109,6 @@ "tests/test_rag_parent_context.py::TestAssemblingAPassage::test_the_match_is_never_shortened_however_small_the_budget": "'budget' names the parent-context character cap, not a spend budget; a size bound, no refusal", "tests/test_rag_parent_context.py::TestAssemblingAPassage::test_a_chunk_too_big_for_the_budget_stops_its_direction": "'budget' names the parent-context character cap, not a spend budget; a size bound, no refusal", "tests/test_rag_parent_context.py::TestExpandingResults::test_the_per_search_budget_is_shared_and_stops_expansion": "'budget' names the per-search parent-context character cap, not a spend budget; a size bound, no refusal", - "tests/test_sandbox_workspace.py::TestContainerBackedWorkspaces::test_a_docker_workspace_labels_its_tenant_and_reattaches": "a docker workspace labels its tenant for accounting and reattaches, feature, no isolation refusal", "tests/test_sandbox_workspace.py::TestDrawingAHostsImages::test_the_budget_bounds_a_page_of_photographs": "a thumbnail budget bounds image reads, resource limit, not a spend budget", "tests/test_sandbox_workspace.py::TestDrawingAHostsImages::test_the_budget_goes_to_the_first_workspaces_listed": "thumbnail fetch budget ordering, not a spend budget", "tests/test_services_organizations.py::TestOrganizationService::test_a_new_team_org_starts_with_the_default_monthly_budget": "a new team org gets the default monthly budget, default config, no refusal", diff --git a/backend/tests/test_skill_workspace.py b/backend/tests/test_skill_workspace.py index 49438dcc9..abd858e15 100644 --- a/backend/tests/test_skill_workspace.py +++ b/backend/tests/test_skill_workspace.py @@ -14,18 +14,18 @@ *Nothing is proposed unless something actually differs.* Otherwise every turn of every conversation would leave a reviewer another copy of the same skill. -Both entry points are awaited. Materialising happens inside `prepare`, once per -file per skill, and on a container-backed workspace each of those writes is a -synchronous HTTP round trip - so an agent with a few skills held the event loop -for a dozen of them before the model saw a token. The backends below stay -synchronous, which is the production arrangement too: `ensure_async` wraps one. +Both entry points are awaited, and work in the run's workspace. The workspaces +below are a stored document, which is what a `state` agent works in; the +failures a container shows are wrappers around one. """ from __future__ import annotations +from collections.abc import Sequence from uuid import uuid4 import pytest +from pydantic_ai.workspaces import FileEntry, Workspace, WrapperWorkspace from pydantic_ai_backends import StateBackend from app.agents.capabilities.sandbox._capped import CappedStateBackend @@ -35,6 +35,7 @@ materialise, render_body, ) +from tests.workspaces import document_workspace pytestmark = pytest.mark.anyio @@ -60,8 +61,33 @@ def __init__( self.resources = resources or [] -def _backend() -> StateBackend: - return StateBackend() +def _document() -> tuple[StateBackend, Workspace]: + document = StateBackend() + return document, document_workspace(document) + + +def _write(document: StateBackend, path: str, text: str) -> None: + document.write_bytes(path, text.encode()) + + +class _WriteFails(WrapperWorkspace): + async def write_bytes(self, path: str, data: bytes) -> None: + raise RuntimeError("no") + + +class _Unmeasured(WrapperWorkspace): + """A listing whose sizes are absent rather than zero.""" + + async def list_dir(self, path: str) -> Sequence[FileEntry]: + return [ + FileEntry(name=e.name, path=e.path, is_dir=e.is_dir, size=None) + for e in await super().list_dir(path) + ] + + +class _ListingFails(WrapperWorkspace): + async def list_dir(self, path: str) -> Sequence[FileEntry]: + raise RuntimeError("the service is down") class TestWritingSkillsIntoTheWorkspace: @@ -69,14 +95,14 @@ async def test_the_body_and_every_resource_land_beside_each_other(self): """Beside each other because that is the whole feature: a script the model could previously only quote is now a path its shell can run.""" skill = _Skill(resources=[_Resource("reconcile.py", "print(1)")]) - backend = _backend() + document, workspace = _document() - await materialise(backend, [skill]) + await materialise(workspace, [skill]) - assert "Ask for the order id." in backend.read(f"{SKILLS_ROOT}/refunds/SKILL.md") - # `read_bytes` because the toolset's `read` numbers lines for a model to - # quote, and the script has to be exactly what the shell will run. - assert backend.read_bytes(f"{SKILLS_ROOT}/refunds/reconcile.py") == b"print(1)" + body = document.read_bytes(f"{SKILLS_ROOT}/refunds/SKILL.md").decode() + assert "Ask for the order id." in body + # Byte for byte: the script has to be exactly what the shell will run. + assert document.read_bytes(f"{SKILLS_ROOT}/refunds/reconcile.py") == b"print(1)" async def test_the_body_carries_the_name_and_description_the_library_parses(self): """The description is what every other agent reads before deciding whether @@ -91,38 +117,35 @@ async def test_a_workspace_that_refuses_a_write_still_runs_the_agent(self): """The skills are in the prompt either way, which is how they worked before this existed. Failing the run over a file would be a regression caused by a convenience.""" - backend = CappedStateBackend(StateBackend(), max_bytes=10) + workspace = document_workspace(CappedStateBackend(max_bytes=10)) - state = await materialise(backend, [_Skill()]) + state = await materialise(workspace, [_Skill()]) assert state.written == {} assert state.owners == {"refunds": state.owners["refunds"]} - async def test_a_backend_that_raises_is_survived_too(self): - class _Broken: - def write(self, path, content): - raise RuntimeError("no") - - assert (await materialise(_Broken(), [_Skill()])).written == {} + async def test_a_workspace_that_raises_is_survived_too(self): + assert (await materialise(_WriteFails(document_workspace()), [_Skill()])).written == {} class TestCollectingWhatTheAgentChanged: async def test_an_untouched_workspace_proposes_nothing(self): - backend = _backend() - state = await materialise(backend, [_Skill(resources=[_Resource("notes.md", "hello")])]) + document, workspace = _document() + state = await materialise(workspace, [_Skill(resources=[_Resource("notes.md", "hello")])]) - assert await collect_changes(backend, state) == [] + assert await collect_changes(workspace, state) == [] async def test_an_edited_body_comes_back_as_the_new_body(self): skill = _Skill() - backend = _backend() - state = await materialise(backend, [skill]) + document, workspace = _document() + state = await materialise(workspace, [skill]) - backend.write( + _write( + document, f"{SKILLS_ROOT}/refunds/SKILL.md", "---\nname: refunds\ndescription: How refunds work now.\n---\n\nAsk for the receipt.", ) - [change] = await collect_changes(backend, state) + [change] = await collect_changes(workspace, state) assert change.skill_id == skill.id assert change.is_new is False @@ -131,25 +154,26 @@ async def test_an_edited_body_comes_back_as_the_new_body(self): async def test_a_new_resource_comes_back_with_the_skill_it_sits_in(self): skill = _Skill() - backend = _backend() - state = await materialise(backend, [skill]) + document, workspace = _document() + state = await materialise(workspace, [skill]) - backend.write(f"{SKILLS_ROOT}/refunds/checklist.md", "- ask for the id") - [change] = await collect_changes(backend, state) + _write(document, f"{SKILLS_ROOT}/refunds/checklist.md", "- ask for the id") + [change] = await collect_changes(workspace, state) assert change.resources == {"checklist.md": "- ask for the id"} async def test_a_skill_the_agent_invented_has_no_id_to_edit(self): """It becomes a *new* skill on approval rather than overwriting one, and the difference is a missing id rather than a flag somebody sets.""" - backend = _backend() - state = await materialise(backend, [_Skill()]) + document, workspace = _document() + state = await materialise(workspace, [_Skill()]) - backend.write( + _write( + document, f"{SKILLS_ROOT}/escalation/SKILL.md", "---\nname: escalation\ndescription: When to escalate.\n---\n\nPage the lead.", ) - [change] = await collect_changes(backend, state) + [change] = await collect_changes(workspace, state) assert change.name == "escalation" assert change.is_new is True @@ -157,79 +181,77 @@ async def test_a_skill_the_agent_invented_has_no_id_to_edit(self): async def test_a_deleted_resource_is_not_a_proposal(self): """Absence cannot be read: a model that never touched the file and one that meant to delete it leave the same workspace.""" - backend = _backend() + document, workspace = _document() state = await materialise( - backend, [_Skill(resources=[_Resource("a.md", "one"), _Resource("b.md", "two")])] + workspace, [_Skill(resources=[_Resource("a.md", "one"), _Resource("b.md", "two")])] ) - backend.write(f"{SKILLS_ROOT}/refunds/a.md", "one") # unchanged + _write(document, f"{SKILLS_ROOT}/refunds/a.md", "one") # unchanged # b.md is simply never mentioned again, which is what a delete looks like. - changes = await collect_changes(backend, state) + changes = await collect_changes(workspace, state) assert changes == [] async def test_a_directory_with_no_body_is_refused_rather_than_filled_in(self): """Inventing an empty body would propose replacing real instructions with nothing.""" - backend = _backend() - state = await materialise(backend, [_Skill()]) + document, workspace = _document() + state = await materialise(workspace, [_Skill()]) - backend.write(f"{SKILLS_ROOT}/stray/notes.md", "just a file") + _write(document, f"{SKILLS_ROOT}/stray/notes.md", "just a file") - assert await collect_changes(backend, state) == [] + assert await collect_changes(workspace, state) == [] async def test_a_host_that_did_not_measure_a_file_does_not_break_the_read(self): """`size` is `int | None`, and a listing carrying the key with `None` - a host that could not measure the file - compared `None` to the cap and raised inside the ingestion of a proposal. Found by typing the backend properly.""" - backend = _backend() - state = await materialise(backend, [_Skill()]) - - class _Unmeasured: - """A listing whose sizes are absent rather than zero.""" + document, workspace = _document() + state = await materialise(workspace, [_Skill()]) - def __init__(self, inner): - self._inner = inner + _write(document, f"{SKILLS_ROOT}/refunds/SKILL.md", "---\nname: refunds\n---\n\nrewritten") - def glob_info(self, pattern, path="/"): - return [{**entry, "size": None} for entry in self._inner.glob_info(pattern)] + [change] = await collect_changes(_Unmeasured(workspace), state) - def __getattr__(self, name): - return getattr(self._inner, name) + assert change.content == "rewritten" - backend.write(f"{SKILLS_ROOT}/refunds/SKILL.md", "---\nname: refunds\n---\n\nrewritten") + async def test_an_unmeasured_file_past_the_ceiling_is_still_dropped(self): + """A container's listing carries no sizes, so the ceiling is checked + against what the file measures - or a file of any size became a proposal.""" + document, workspace = _document() + state = await materialise(workspace, [_Skill()]) - [change] = await collect_changes(_Unmeasured(backend), state) + _write(document, f"{SKILLS_ROOT}/refunds/huge.md", "x" * (300 * 1024)) - assert change.content == "rewritten" + assert await collect_changes(_Unmeasured(workspace), state) == [] async def test_frontmatter_the_model_mangled_is_refused_rather_than_guessed_at(self): """The description is what other agents read first; a guess at it is a guess at what this skill claims to be.""" - backend = _backend() - state = await materialise(backend, [_Skill()]) + document, workspace = _document() + state = await materialise(workspace, [_Skill()]) - backend.write(f"{SKILLS_ROOT}/refunds/SKILL.md", "---\n: : :\nnot: [yaml\n---\n\nbody") + _write(document, f"{SKILLS_ROOT}/refunds/SKILL.md", "---\n: : :\nnot: [yaml\n---\n\nbody") - assert await collect_changes(backend, state) == [] + assert await collect_changes(workspace, state) == [] async def test_frontmatter_that_is_not_a_mapping_is_refused_the_same_way(self): """Valid YAML, wrong shape - a list or a sentence where keys were expected.""" - backend = _backend() - state = await materialise(backend, [_Skill()]) + document, workspace = _document() + state = await materialise(workspace, [_Skill()]) - backend.write(f"{SKILLS_ROOT}/refunds/SKILL.md", "---\n- refunds\n---\n\nbody") + _write(document, f"{SKILLS_ROOT}/refunds/SKILL.md", "---\n- refunds\n---\n\nbody") - assert await collect_changes(backend, state) == [] + assert await collect_changes(workspace, state) == [] async def test_a_body_with_no_frontmatter_proposes_an_empty_description(self): """Accepted rather than refused: the instructions are there and readable, and a reviewer can see the description is missing and fill it in.""" - backend = _backend() - state = await materialise(backend, [_Skill()]) + document, workspace = _document() + state = await materialise(workspace, [_Skill()]) - backend.write(f"{SKILLS_ROOT}/refunds/SKILL.md", "Ask for the receipt.") - [change] = await collect_changes(backend, state) + _write(document, f"{SKILLS_ROOT}/refunds/SKILL.md", "Ask for the receipt.") + [change] = await collect_changes(workspace, state) assert change.description == "" assert change.content == "Ask for the receipt." @@ -237,57 +259,70 @@ async def test_a_body_with_no_frontmatter_proposes_an_empty_description(self): async def test_a_file_nested_deeper_than_a_skill_belongs_to_no_skill(self): """A skill is a directory of files. Treating `/workspace/skills/a/b/c` as `a`'s would flatten two paths onto one resource name.""" - backend = _backend() - state = await materialise(backend, [_Skill()]) + document, workspace = _document() + state = await materialise(workspace, [_Skill()]) + + _write(document, f"{SKILLS_ROOT}/refunds/deep/nested.md", "hidden") + + assert await collect_changes(workspace, state) == [] + + async def test_a_loose_file_beside_the_skills_belongs_to_no_skill(self): + """A skill is a directory; a file the agent left directly under the skills + root has no name to be proposed under.""" + document, workspace = _document() + state = await materialise(workspace, [_Skill()]) - backend.write(f"{SKILLS_ROOT}/refunds/deep/nested.md", "hidden") + _write(document, f"{SKILLS_ROOT}/notes.md", "scratch") - assert await collect_changes(backend, state) == [] + assert await collect_changes(workspace, state) == [] async def test_a_file_past_the_ceiling_is_dropped_rather_than_truncated(self): """Half a script is not a script, and storing it would offer a reviewer something that cannot be right.""" - backend = _backend() - state = await materialise(backend, [_Skill()]) + document, workspace = _document() + state = await materialise(workspace, [_Skill()]) - backend.write(f"{SKILLS_ROOT}/refunds/huge.md", "x" * (300 * 1024)) - changes = await collect_changes(backend, state) + _write(document, f"{SKILLS_ROOT}/refunds/huge.md", "x" * (300 * 1024)) + changes = await collect_changes(workspace, state) assert changes == [] - async def test_a_directory_in_the_listing_is_not_read_as_a_file(self): - """`StateBackend` reports only files; a container-backed workspace lists a - real filesystem, where `/workspace/skills/refunds` is itself an entry. Reading it - would raise where nothing is wrong.""" + async def test_a_directory_in_a_skill_is_not_read_as_a_file(self): + """A listing names directories too; reading one would raise where nothing + is wrong.""" + document, workspace = _document() + state = await materialise(workspace, [_Skill()]) + _write(document, f"{SKILLS_ROOT}/refunds/SKILL.md", "body") + document.make_dir(f"{SKILLS_ROOT}/refunds/drafts") - class _WithDirectories: - def glob_info(self, pattern, path="/"): - return [ - {"path": f"{SKILLS_ROOT}/refunds", "is_dir": True, "size": 0}, - {"path": f"{SKILLS_ROOT}/refunds/SKILL.md", "is_dir": False, "size": 4}, - ] + [change] = await collect_changes(workspace, state) - def read_bytes(self, path): - assert path.endswith("SKILL.md") - return b"body" + assert change.content == "body" - state = await materialise(_backend(), [_Skill()]) + async def test_a_dot_named_resource_is_proposed_like_any_other(self): + document, workspace = _document() + state = await materialise(workspace, [_Skill()]) + _write(document, f"{SKILLS_ROOT}/refunds/.gitignore", "*.tmp") - [change] = await collect_changes(_WithDirectories(), state) + [change] = await collect_changes(workspace, state) - assert change.content == "body" + assert change.resources == {".gitignore": "*.tmp"} + + async def test_no_skills_folder_proposes_nothing(self): + assert ( + await collect_changes( + document_workspace(), (await materialise(document_workspace(), [])) + ) + == [] + ) async def test_a_workspace_that_cannot_be_listed_proposes_nothing(self): """Which is the same as one that changed nothing - and it runs in the `finally` that records what the run cost, so it cannot raise.""" - class _Broken: - def glob_info(self, pattern, path="/"): - raise RuntimeError("the service is down") - - state = await materialise(_backend(), [_Skill()]) + state = await materialise(_document()[1], [_Skill()]) - assert await collect_changes(_Broken(), state) == [] + assert await collect_changes(_ListingFails(document_workspace()), state) == [] @pytest.mark.parametrize( diff --git a/backend/tests/test_subagent_resolution.py b/backend/tests/test_subagent_resolution.py index 97a65dade..bd5456b0d 100644 --- a/backend/tests/test_subagent_resolution.py +++ b/backend/tests/test_subagent_resolution.py @@ -36,7 +36,7 @@ from pydantic_ai.models.function import AgentInfo, FunctionModel from app.agents.capabilities.budget import SpendLedger -from app.agents.capabilities.sandbox import WORKSPACE_BACKEND_RESOURCE +from app.agents.capabilities.sandbox import WORKSPACE_RESOURCE from app.agents.capabilities.subagents import SubagentsConfig from app.agents.spec import ( AgentSpec, @@ -311,7 +311,9 @@ async def resolve_model(_ctx: Any, *, profile_id: uuid.UUID | None) -> Any: patch.object( service.workspaces, "open", - new=AsyncMock(return_value=None if workspace is None else MagicMock(backend=workspace)), + new=AsyncMock( + return_value=None if workspace is None else MagicMock(workspace=workspace) + ), ), patch(f"{RUNNER}.workspace_snapshot", new=AsyncMock(return_value=set())), patch( @@ -507,29 +509,55 @@ class TestSharingACapabilityWithADelegate: work.""" async def test_a_shared_workspace_is_the_parents_own_session(self): - backend = MagicMock(name="workspace-backend") + workspace = MagicMock(name="workspace") spec = _delegating( inline=[_specialist()], share=["sandbox"], capabilities=[{"id": "sandbox", "config": {}}], ) - prepared = await _prepare(spec, workspace=backend) + prepared = await _prepare(spec, workspace=workspace) built = prepared.built("summariser") - assert built["resources"][WORKSPACE_BACKEND_RESOURCE] is backend + assert built["resources"][WORKSPACE_RESOURCE] is workspace # The binding travels as it stands, so the delegate reads and writes with # the configuration the parent was published with. assert [binding.id for binding in built["spec"].capabilities] == ["sandbox"] + @pytest.mark.parametrize( + ("share", "own", "expected"), + [ + (["sandbox"], [], "parent"), + ([], [{"id": "sandbox", "config": {}}], "own"), + ([], [], "none"), + ], + ids=["shared", "binds-its-own", "binds-none"], + ) + async def test_the_share_list_decides_which_workspace_a_delegation_runs_in( + self, share, own, expected + ): + """The delegation library hands every delegation the parent's workspace, + so this decision is what keeps a delegate nobody shared `sandbox` with out + of the parent's files.""" + spec = _delegating( + inline=[_specialist(capabilities=own)], + share=share, + capabilities=[{"id": "sandbox", "config": {}}], + ) + + prepared = await _prepare(spec, workspace=MagicMock(name="workspace")) + + [entry] = prepared.runtime.subagents + assert entry.workspace == expected + async def test_a_capability_that_is_not_shared_does_not_travel(self): - backend = MagicMock(name="workspace-backend") + workspace = MagicMock(name="workspace") spec = _delegating(inline=[_specialist()], capabilities=[{"id": "sandbox", "config": {}}]) - prepared = await _prepare(spec, workspace=backend) + prepared = await _prepare(spec, workspace=workspace) built = prepared.built("summariser") - assert WORKSPACE_BACKEND_RESOURCE not in built["resources"] + assert WORKSPACE_RESOURCE not in built["resources"] assert built["spec"].capabilities == [] async def test_a_delegates_own_binding_wins_over_a_shared_one(self): diff --git a/backend/tests/test_subagents_capability.py b/backend/tests/test_subagents_capability.py index 20465488c..6401ae276 100644 --- a/backend/tests/test_subagents_capability.py +++ b/backend/tests/test_subagents_capability.py @@ -28,7 +28,7 @@ import logging import re from collections.abc import AsyncIterator, Awaitable, Callable -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import UTC, datetime from decimal import Decimal from typing import Any @@ -64,6 +64,7 @@ from pydantic_ai.tools import DeferredToolRequests from pydantic_ai.toolsets import FunctionToolset from pydantic_ai.usage import RunUsage, UsageLimits +from pydantic_ai_backends import StateBackend from subagents_pydantic_ai import SubAgentConfig, TaskHandle, TaskManager, TaskStatus from app.agents.capabilities import CapabilityBinding, CapabilityBuildContext, build, get @@ -74,6 +75,7 @@ approval_required_tools, ) from app.agents.capabilities.budget import BudgetExceeded, BudgetScope, SpendEntry, SpendLedger +from app.agents.capabilities.sandbox._capability import build_workspace from app.agents.capabilities.subagents import Delegation, SubagentsConfig from app.agents.capabilities.subagents._capability import ( BACKGROUND_LIFECYCLE_TOOLS, @@ -90,10 +92,12 @@ from app.agents.subagent_events import SubagentEvent, SubagentFinished from app.agents.subagent_runtime import ( SUBAGENT_RUNTIME_RESOURCE, + DelegateWorkspace, DelegationOutcome, ResolvedSubagent, SubagentRuntime, ) +from tests.workspaces import document_workspace pytestmark = pytest.mark.anyio @@ -2352,6 +2356,78 @@ async def test_the_delegate_s_own_tool_calls_are_reported(self): assert calls[0].tool_name == "ping" +def _writes_notes(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: + """A delegate's model: write `notes.md`, then answer.""" + if any(isinstance(part, ToolReturnPart) for part in messages[-1].parts): + return ModelResponse(parts=[TextPart("noted")]) + return ModelResponse( + parts=[ToolCallPart("write_file", {"path": "/notes.md", "content": "found it"})] + ) + + +def _a_writing_delegate(workspace: DelegateWorkspace) -> ResolvedSubagent: + """A delegate binding `sandbox`, under the given workspace decision.""" + return ResolvedSubagent( + name="researcher", + description="Researches a topic and cites its sources.", + build=lambda: PydanticAgent( + FunctionModel(_writes_notes), + deps_type=AgentDeps, + capabilities=[build_workspace(include_execute=False)], + ), + workspace=workspace, + ) + + +class TestTheWorkspaceADelegationRunsIn: + """`share_with_delegates` decides who works in the parent's files. + + The delegation library hands every delegation the parent's workspace whenever + one is attached, so this is the platform's own decision to enforce: without + it a delegate nobody shared `sandbox` with ran its own shell in the parent's + container. + """ + + async def test_a_delegate_shared_the_sandbox_writes_into_the_parents_files(self): + parents = StateBackend() + ctx = replace(a_context(), workspace=document_workspace(parents)) + + await delegate_to(a_capability(a_runtime(_a_writing_delegate("parent"))), ctx) + + assert parents.read_bytes("/notes.md") == b"found it" + + @pytest.mark.security + async def test_a_delegate_with_a_sandbox_of_its_own_never_touches_the_parents(self): + parents = StateBackend() + ctx = replace(a_context(), workspace=document_workspace(parents)) + + answer = await delegate_to(a_capability(a_runtime(_a_writing_delegate("own"))), ctx) + + assert "noted" in str(answer) + assert parents.files == {} + + async def test_a_delegate_with_no_sandbox_is_handed_no_workspace(self): + """A delegate binding no `sandbox` - `artifacts` alone, say - read and + wrote nothing of the parent's before, and must not start to.""" + seen: list[bool] = [] + + def ping(ctx: RunContext[AgentDeps]) -> str: + seen.append(ctx.workspace is not None and ctx.workspace.attached) + return "looked" + + delegate = ResolvedSubagent( + name="researcher", + description="Researches a topic and cites its sources.", + build=lambda: PydanticAgent(one_tool_call(), deps_type=AgentDeps, tools=[ping]), + workspace="none", + ) + ctx = replace(a_context(), workspace=document_workspace()) + + await delegate_to(a_capability(a_runtime(delegate)), ctx) + + assert seen == [False] + + class TestDelegateDeps: """What a delegation actually runs with, which is not what its build produced.""" diff --git a/backend/tests/test_system_reminders_capability.py b/backend/tests/test_system_reminders_capability.py index 0949be12f..36bb63500 100644 --- a/backend/tests/test_system_reminders_capability.py +++ b/backend/tests/test_system_reminders_capability.py @@ -90,7 +90,7 @@ async def _run( usage: RunUsage | None = None, usage_limits: UsageLimits | None = None, ) -> ModelRequestContext: - """Drive one `wrap_model_request` and return the (mutated) request context.""" + """Drive one `before_model_request` and return the request it hands on.""" ctx: RunContext[None] = RunContext( deps=None, model=model if model is not None else TestModel(), @@ -98,13 +98,7 @@ async def _run( messages=messages, usage_limits=usage_limits, ) - request_context = _request_context(messages, model=model) - - async def handler(_rc: ModelRequestContext) -> ModelResponse: - return _response() - - await capability.wrap_model_request(ctx, request_context=request_context, handler=handler) - return request_context + return await capability.before_model_request(ctx, _request_context(messages, model=model)) def _tail_reminder(request_context: ModelRequestContext) -> UserPromptPart | None: @@ -543,3 +537,64 @@ async def test_goal_reanchor_producer_uses_the_goal(self): async def test_llm_reminder_producer_returns_a_callable(self): producer = llm_reminder_producer(instructions="x", max_context_messages=3, fallback="f") assert isinstance(producer, _LlmReminder) + + +class TestARemindersSurvivesCompaction: + """Since pydantic-ai 2.54 a compaction strategy rebuilds the request from the + run's history inside the same before-chain this capability injects in, so the + reminder has to be appended after it, and must still stay out of history.""" + + async def test_a_compacted_request_still_carries_the_reminder(self): + from pydantic_ai import Agent + from pydantic_ai.models.function import AgentInfo, FunctionModel + + from app.agents.capabilities.compaction import ( + CompactionConfig, + MeteredCompaction, + build_strategy, + ) + + seen: list[bool] = [] + + def respond(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse: + tail = messages[-1] + seen.append( + isinstance(tail, ModelRequest) + and any( + isinstance(part, UserPromptPart) and "stay focused" in str(part.content) + for part in tail.parts + ) + ) + return ModelResponse(parts=[TextPart("ok")]) + + compaction = MeteredCompaction( + wrapped=build_strategy( + CompactionConfig( + strategy="sliding_window", + keep_messages=1, + max_fraction=0.05, + context_window=1_000, + ) + ) + ) + reminders = _reminders(reminders=[{"content": "stay focused"}]) + agent = Agent(FunctionModel(respond), capabilities=[compaction, reminders]) + history: list[ModelMessage] = [ + message + for index in range(10) + for message in ( + _user_request(f"turn {index}: " + "words " * 20), + ModelResponse(parts=[TextPart("fine")]), + ) + ] + + result = await agent.run("next", message_history=history) + + assert seen == [True] + assert not any( + "stay focused" in str(part.content) + for message in result.all_messages() + if isinstance(message, ModelRequest) + for part in message.parts + if isinstance(part, UserPromptPart) + ) diff --git a/backend/tests/test_tool_output_limits.py b/backend/tests/test_tool_output_limits.py index 573f9535e..385ad9f4b 100644 --- a/backend/tests/test_tool_output_limits.py +++ b/backend/tests/test_tool_output_limits.py @@ -1,8 +1,8 @@ """Tests for the tool-output-limits capability. What is guarded: an oversized return is reduced once at production time and not -re-sent in full; a spill goes to the run's own backend and reads back by handle; -a spill the backend refuses degrades to a visible truncation rather than a silent +re-sent in full; a spill goes to the run's own workspace and reads back by handle; +a spill the workspace refuses degrades to a visible truncation rather than a silent drop; a `summarize` call is booked against the run that paid for it; and an agent that does not bind the capability gets nothing - no read-back tool, no reduction. """ @@ -19,7 +19,7 @@ from pydantic_ai.models.test import TestModel from pydantic_ai.tools import ToolDefinition from pydantic_ai.usage import RunUsage -from pydantic_ai_backends import AsyncBackendAdapter, StateBackend, WriteResult +from pydantic_ai.workspaces import Workspace, WrapperWorkspace from pydantic_ai_harness.tool_output_limits import Spill, Summarize, Truncate from app.agents.capabilities import CapabilityBinding, build, get @@ -32,14 +32,14 @@ guarded_by, metered_by, ) -from app.agents.capabilities.sandbox import WORKSPACE_BACKEND_RESOURCE +from app.agents.capabilities.sandbox import WORKSPACE_RESOURCE from app.agents.capabilities.tool_output_limits import ( DEFAULT_SUMMARY_PROMPT, SPILL_LOG_RESOURCE, - BackendOverflowStore, MeteredToolOutputLimits, OverflowWriteError, ToolOutputLimitsConfig, + WorkspaceOverflowStore, build_limits, ) from app.agents.capabilities.tool_output_limits._capability import ( @@ -47,6 +47,7 @@ _build_store, readable_return, ) +from tests.workspaces import ShellFailing, document_workspace pytestmark = pytest.mark.anyio @@ -65,18 +66,15 @@ def _tool_def(name: str = "grep") -> ToolDefinition: return ToolDefinition(name=name) -@dataclass -class _RefusingBackend: - """A backend that refuses every write, standing in for a workspace at its cap.""" +class _Refusing(WrapperWorkspace): + """A workspace that refuses every write, standing in for one at its cap.""" - async def write(self, path: str, content: str | bytes) -> WriteResult: - return WriteResult(error="the workspace is full") + async def write_bytes(self, path: str, data: bytes) -> None: + raise OSError(28, "the workspace is full") - async def exists(self, path: str) -> bool: # pragma: no cover - never read from - return False - async def read_bytes(self, path: str) -> bytes: # pragma: no cover - never read from - return b"" +def _refusing() -> Workspace: + return _Refusing(document_workspace()) @dataclass @@ -150,67 +148,77 @@ def test_summarize_falls_back_through_spill_to_truncation(self): class TestStore: async def test_a_spill_reads_back_by_the_handle_it_returned(self): - store = BackendOverflowStore(StateBackend()) + store = WorkspaceOverflowStore(document_workspace()) handle = await store.write("run-1/call-1.0", b"payload\nsecond line") assert await store.read(handle) == b"payload\nsecond line" - async def test_an_async_backend_is_awaited(self): - """`state` is synchronous; a container backend is not. One adapter, both.""" - store = BackendOverflowStore(AsyncBackendAdapter(StateBackend())) - handle = await store.write("run-1/call-1.0", b"async payload") - assert await store.read(handle) == b"async payload" + async def test_the_handle_is_the_path_the_workspace_resolved(self): + """What a later read, and the prune at close, have to name exactly.""" + store = WorkspaceOverflowStore(document_workspace()) + handle = await store.write("run-1/call-1.0", b"x") + assert handle == "/tool_output/run-1/call-1.0" async def test_a_refused_write_raises_so_spill_can_fall_back(self): - store = BackendOverflowStore(_RefusingBackend()) + store = WorkspaceOverflowStore(_refusing()) with pytest.raises(OverflowWriteError): await store.write("run-1/call-1.0", b"too big") async def test_an_unknown_handle_raises_rather_than_returning_empty(self): - """The backend answers a missing path with empty bytes; the read-back tool - needs a raised error to tell the model the handle is unknown.""" - store = BackendOverflowStore(StateBackend()) + """The read-back tool needs a raised error to tell the model the handle is + unknown, rather than empty bytes.""" + store = WorkspaceOverflowStore(document_workspace()) with pytest.raises(FileNotFoundError): await store.read("tool_output/never-written") async def test_a_written_handle_is_recorded_in_the_run_spill_log(self): - """What lands in the log is the handle as the backend normalized it, so the + """What lands in the log is the handle as the workspace resolved it, so the prune at workspace close deletes the path that actually exists (#803).""" log: list[str] = [] - store = BackendOverflowStore(StateBackend(), spill_log=log) + store = WorkspaceOverflowStore(document_workspace(), spill_log=log) first = await store.write("run-1/call-1.0", b"payload") second = await store.write("run-1/call-2.0", b"payload") assert log == [first, second] + async def test_a_container_s_failed_transfer_falls_back_too(self): + """A container moves the bytes through its shell, whose failure is a + `WorkspaceError` rather than the `OSError` of a full document.""" + store = WorkspaceOverflowStore(ShellFailing(document_workspace())) + with pytest.raises(OverflowWriteError): + await store.write("run-1/call-1.0", b"payload") + async def test_a_refused_write_records_nothing(self): log: list[str] = [] - store = BackendOverflowStore(_RefusingBackend(), spill_log=log) + store = WorkspaceOverflowStore(_refusing(), spill_log=log) with pytest.raises(OverflowWriteError): await store.write("run-1/call-1.0", b"too big") assert log == [] class TestBuildStore: - def test_no_backend_falls_back_to_an_in_memory_one(self): + async def test_no_workspace_falls_back_to_an_in_memory_one(self): store = _build_store(None) - assert isinstance(store.backend, StateBackend) + handle = await store.write("k", b"kept") + assert await store.read(handle) == b"kept" - def test_a_backend_is_used_as_given(self): - backend = StateBackend() - assert _build_store(backend).backend is backend + def test_a_workspace_is_used_as_given(self): + workspace = document_workspace() + assert _build_store(workspace).workspace is workspace def test_the_in_memory_fallback_records_no_handles(self): - """A backend discarded with the run leaves nothing to delete, so tracking + """A document discarded with the run leaves nothing to delete, so tracking its spills would offer the prune paths that no longer exist.""" assert _build_store(None, ["polluted"]).spill_log is None - def test_a_bound_backend_records_into_the_run_log(self): + def test_a_bound_workspace_records_into_the_run_log(self): log: list[str] = [] - assert _build_store(StateBackend(), log).spill_log is log + assert _build_store(document_workspace(), log).spill_log is log class TestReduction: async def test_a_small_return_passes_through_untouched(self): - limits = build_limits(ToolOutputLimitsConfig(threshold=10_000), backend=StateBackend()) + limits = build_limits( + ToolOutputLimitsConfig(threshold=10_000), workspace=document_workspace() + ) out = await limits.after_tool_execute( _run_context(), call=_call(), tool_def=_tool_def(), args={}, result="small" ) @@ -219,7 +227,7 @@ async def test_a_small_return_passes_through_untouched(self): async def test_a_page_of_forty_thousand_characters_arrives_whole_by_default(self): """At the old 10,000 a fetched page arrived as a preview to page through, which read as the tool being broken; an ordinary page now passes as it is.""" - limits = build_limits(ToolOutputLimitsConfig(), backend=StateBackend()) + limits = build_limits(ToolOutputLimitsConfig(), workspace=document_workspace()) page = "x" * 40_000 out = await limits.after_tool_execute( _run_context(), call=_call(), tool_def=_tool_def(), args={}, result=page @@ -230,7 +238,7 @@ async def test_a_page_of_forty_thousand_characters_arrives_whole_by_default(self async def test_a_full_fetch_arrives_whole_with_its_url_and_title(self): """`web_fetch` returns up to 50,000 characters of content by default, and the URL, title and truncation marker come on top of it.""" - limits = build_limits(ToolOutputLimitsConfig(), backend=StateBackend()) + limits = build_limits(ToolOutputLimitsConfig(), workspace=document_workspace()) fetched = { "url": "https://example.com/" + "a" * 500, "title": "A long page", @@ -252,7 +260,7 @@ async def test_a_lowered_threshold_still_shortens_what_crosses_it(self): return as it was; left unset, what is kept follows the threshold.""" config = ToolOutputLimitsConfig(action="truncate", threshold=10_000) assert config.max_chars == 10_000 - limits = build_limits(config, backend=StateBackend()) + limits = build_limits(config, workspace=document_workspace()) out = await limits.after_tool_execute( _run_context(), call=_call(), tool_def=_tool_def(), args={}, result="z" * 15_000 ) @@ -266,7 +274,7 @@ async def test_a_spilled_page_pages_by_line_not_as_one_json_line(self): """An MCP page arrives as `{"title": ..., "text": "..."}`. Spilled as compact JSON it was one line, and every `read_tool_result` answered "1 matching line, output capped"; its text is now what is paged.""" - limits = build_limits(ToolOutputLimitsConfig(threshold=500), backend=StateBackend()) + limits = build_limits(ToolOutputLimitsConfig(threshold=500), workspace=document_workspace()) page = { "metadata": {"type": "block"}, "title": "VstormPedia", @@ -294,7 +302,7 @@ def test_a_return_with_no_text_to_page_is_indented_json(self): async def test_an_oversized_return_is_spilled_and_reads_back_in_full(self): limits = build_limits( - ToolOutputLimitsConfig(action="spill", threshold=500), backend=StateBackend() + ToolOutputLimitsConfig(action="spill", threshold=500), workspace=document_workspace() ) payload = "x" * 5_000 out = await limits.after_tool_execute( @@ -305,10 +313,10 @@ async def test_an_oversized_return_is_spilled_and_reads_back_in_full(self): handle = out.metadata["overflow_handle"] assert (await limits.store.read(handle)).decode() == payload - async def test_a_spill_the_backend_refuses_degrades_to_truncation(self): + async def test_a_spill_the_workspace_refuses_degrades_to_truncation(self): limits = build_limits( ToolOutputLimitsConfig(action="spill", threshold=500, max_chars=200), - backend=_RefusingBackend(), + workspace=_refusing(), ) out = await limits.after_tool_execute( _run_context(), call=_call(), tool_def=_tool_def(), args={}, result="y" * 5_000 @@ -321,7 +329,7 @@ class TestBuildLimits: def test_the_configuration_reaches_the_capability(self): limits = build_limits( ToolOutputLimitsConfig(threshold=42_000, over_tokens=True, strip_ansi=True), - backend=StateBackend(), + workspace=document_workspace(), ) assert limits.over_tokens is True assert limits.strip_ansi is True @@ -356,7 +364,7 @@ async def test_a_real_summarize_books_its_model_call(self): capability = MeteredToolOutputLimits( wrapped=build_limits( ToolOutputLimitsConfig(action="summarize", threshold=500), - backend=StateBackend(), + workspace=document_workspace(), ) ) @@ -412,14 +420,14 @@ async def test_binding_it_offers_the_read_back_tool(self): assert toolset is not None assert "read_tool_result" in await toolset.get_tools(_run_context()) - def test_a_bound_backend_is_used_for_spills(self): - backend = StateBackend() + def test_a_bound_workspace_is_used_for_spills(self): + workspace = document_workspace() built = build( [CapabilityBinding(capability_id=CAPABILITY_ID)], - resources={WORKSPACE_BACKEND_RESOURCE: backend}, + resources={WORKSPACE_RESOURCE: workspace}, ) limits = built[0].wrapped - assert limits.store.backend is backend + assert limits.store.workspace is workspace async def test_a_spill_is_recorded_in_the_workspace_spill_log(self): """The runner's log resource reaches the store, so a spill on a shared @@ -427,7 +435,7 @@ async def test_a_spill_is_recorded_in_the_workspace_spill_log(self): log: list[str] = [] built = build( [CapabilityBinding(capability_id=CAPABILITY_ID, config={"threshold": 500})], - resources={WORKSPACE_BACKEND_RESOURCE: StateBackend(), SPILL_LOG_RESOURCE: log}, + resources={WORKSPACE_RESOURCE: document_workspace(), SPILL_LOG_RESOURCE: log}, ) out = await built[0].after_tool_execute( _run_context(), call=_call(), tool_def=_tool_def(), args={}, result="x" * 5_000 diff --git a/backend/tests/test_tool_text_shape.py b/backend/tests/test_tool_text_shape.py index f0aeed64f..de772b653 100644 --- a/backend/tests/test_tool_text_shape.py +++ b/backend/tests/test_tool_text_shape.py @@ -16,15 +16,12 @@ from unittest.mock import MagicMock import pytest -from pydantic_ai import RunContext -from pydantic_ai.models.test import TestModel from pydantic_ai.toolsets import FunctionToolset -from pydantic_ai.usage import RunUsage from app.agents.capabilities import all_capabilities, load_builtins from app.agents.capabilities._registry import CapabilityBinding, build, get from app.agents.capabilities._tool_text import ToolText -from app.services.capability_contracts import tool_contracts +from app.services.capability_contracts import probe_context, tool_contracts pytestmark = pytest.mark.anyio @@ -34,16 +31,12 @@ def _builtins_loaded() -> None: load_builtins() -def _ctx() -> RunContext[None]: - return RunContext(deps=None, model=TestModel(), usage=RunUsage()) - - async def _described(capability_id: str, config: dict[str, object] | None = None) -> dict[str, str]: """Every tool one capability offers, and the text the model reads for it.""" built = build([CapabilityBinding(capability_id=capability_id, config=config or {})]) toolset = built[0].get_toolset() assert toolset is not None - tools = await toolset.get_tools(_ctx()) + tools = await toolset.get_tools(probe_context(built[0])) return {name: tool.tool_def.description or "" for name, tool in tools.items()} diff --git a/backend/tests/workspaces.py b/backend/tests/workspaces.py new file mode 100644 index 000000000..915724ee5 --- /dev/null +++ b/backend/tests/workspaces.py @@ -0,0 +1,34 @@ +"""In-memory workspaces for tests: a stored document, worked in as a run works in one.""" + +from __future__ import annotations + +from pydantic_ai.workspaces import Workspace, WorkspaceError, WorkspaceRef, WrapperWorkspace +from pydantic_ai_backends import StateBackend +from pydantic_ai_backends.workspaces import StateWorkspaceBackend + + +def document_workspace(document: StateBackend | None = None, *, key: str = "doc") -> Workspace: + """`document` - an empty one by default - as the workspace a run works in. + + Files only: the document has no shell, as a `state` workspace has none. + """ + backend = StateWorkspaceBackend( + {key: document if document is not None else StateBackend()}, + ref=WorkspaceRef(provider="state", id=key), + ) + return Workspace(backend) + + +class ShellFailing(WrapperWorkspace): + """A workspace whose file transfers fail the way a container's do. + + A sandbox with no native filesystem moves bytes through its shell, and when + that fails - a full disk, an image missing `base64`, output cut short - it + raises `WorkspaceError`, not the `OSError` a `state` document raises. + """ + + async def write_bytes(self, path: str, data: bytes) -> None: + raise WorkspaceError(f"shell filesystem write failed for {path!r}") + + async def read_bytes(self, path: str) -> bytes: + raise WorkspaceError(f"shell filesystem read failed for {path!r}") diff --git a/backend/uv.lock b/backend/uv.lock index 1d2e62558..5b3c442a8 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -47,7 +47,7 @@ dependencies = [ { name = "prefect" }, { name = "psycopg2-binary" }, { name = "pydantic", extra = ["email"] }, - { name = "pydantic-ai-backend", extra = ["console", "remote"] }, + { name = "pydantic-ai-backend", extra = ["console", "daytona", "remote", "workspaces"] }, { name = "pydantic-ai-harness" }, { name = "pydantic-ai-skills" }, { name = "pydantic-ai-slim", extra = ["anthropic", "cohere", "duckduckgo", "google", "groq", "huggingface", "mcp", "mistral", "openrouter", "web-fetch", "xai"] }, @@ -143,12 +143,12 @@ requires-dist = [ { name = "prefect", specifier = ">=3.8.5" }, { name = "psycopg2-binary", specifier = ">=2.9.13" }, { name = "pydantic", extras = ["email"], specifier = ">=2.13.5" }, - { name = "pydantic-ai-backend", extras = ["console", "remote"], specifier = ">=0.2.29" }, - { name = "pydantic-ai-harness", specifier = ">=0.35.0" }, + { name = "pydantic-ai-backend", extras = ["console", "remote", "workspaces", "daytona"], specifier = ">=0.2.32" }, + { name = "pydantic-ai-harness", specifier = ">=0.52.0" }, { name = "pydantic-ai-skills", specifier = ">=2.0.0" }, - { name = "pydantic-ai-slim", extras = ["mcp"], specifier = ">=2.45.0" }, - { name = "pydantic-ai-slim", extras = ["openrouter", "anthropic", "google", "duckduckgo", "web-fetch", "groq", "mistral", "cohere", "huggingface", "xai"], specifier = ">=2.45.0" }, - { name = "pydantic-ai-slim", extras = ["typesafe"], marker = "extra == 'browser'", specifier = ">=2.45.0" }, + { name = "pydantic-ai-slim", extras = ["mcp"], specifier = ">=2.52.0" }, + { name = "pydantic-ai-slim", extras = ["openrouter", "anthropic", "google", "duckduckgo", "web-fetch", "groq", "mistral", "cohere", "huggingface", "xai"], specifier = ">=2.52.0" }, + { name = "pydantic-ai-slim", extras = ["typesafe"], marker = "extra == 'browser'", specifier = ">=2.52.0" }, { name = "pydantic-monty", specifier = ">=1.0.0" }, { name = "pydantic-settings", specifier = ">=2.15.0" }, { name = "pyjwt", specifier = ">=2.15.0" }, @@ -161,7 +161,7 @@ requires-dist = [ { name = "requests", specifier = ">=2.34.2" }, { name = "slack-sdk", specifier = ">=3.44.1" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.1.0" }, - { name = "subagents-pydantic-ai", specifier = ">=0.2.22" }, + { name = "subagents-pydantic-ai", specifier = ">=0.2.25" }, { name = "tabulate", specifier = ">=0.9.0" }, { name = "tavily-python", specifier = ">=0.8.4" }, { name = "tqdm", specifier = ">=4.70.1" }, @@ -341,6 +341,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, ] +[[package]] +name = "aiohttp-retry" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/61/ebda4d8e3d8cfa1fd3db0fb428db2dd7461d5742cea35178277ad180b033/aiohttp_retry-2.9.1.tar.gz", hash = "sha256:8eb75e904ed4ee5c2ec242fefe85bf04240f685391c4879d8f541d6028ff01f1", size = 13608, upload-time = "2024-11-06T10:44:54.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/99/84ba7273339d0f3dfa57901b846489d2e5c2cd731470167757f1935fffbd/aiohttp_retry-2.9.1-py3-none-any.whl", hash = "sha256:66d2759d1921838256a05a3f80ad7e724936f083e35be5abb5e16eed6be6dc54", size = 9981, upload-time = "2024-11-06T10:44:52.917Z" }, +] + [[package]] name = "aiosignal" version = "1.4.0" @@ -415,7 +427,7 @@ wheels = [ [[package]] name = "anthropic" -version = "1.6.0" +version = "1.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -426,9 +438,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/3d/4170318539de0c163e1806509b0bb9cd7d611ee15e7885f1d16a11a37d29/anthropic-1.6.0.tar.gz", hash = "sha256:ce3c032f940984f67c516db896375bff36959f8dc589973e386f8e6ea81bbec3", size = 1172375, upload-time = "2026-09-15T15:14:55.826Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/12/9a6ffa397b172adb040008d934a1dfd85d0e4cefc77489416db294ffc880/anthropic-1.11.0.tar.gz", hash = "sha256:3906fabac7ad7b5b46c6186040398fc7826885c77ce34e4dd7849de16fc8d0f8", size = 1390390, upload-time = "2026-09-30T22:58:21.098Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/32/57a8b6c5441d9e6a1f332f70ee02375af6d9b5b65b42689ba83f7c7d010a/anthropic-1.6.0-py3-none-any.whl", hash = "sha256:049434f013d3a874acdfee696ec626e19166d58f4277e8fa34e5d0a5ec250ad0", size = 1249068, upload-time = "2026-09-15T15:14:54.326Z" }, + { url = "https://files.pythonhosted.org/packages/ab/6f/20edcd2956cac68042b1bcdd13ab3311447519f86b26929ff268dc74c650/anthropic-1.11.0-py3-none-any.whl", hash = "sha256:52f97b2c485cca7ac66058374f5073a3febb7e6849b16989c145d602a3efee21", size = 1681654, upload-time = "2026-09-30T22:58:22.925Z" }, ] [[package]] @@ -669,6 +681,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, ] +[[package]] +name = "bidict" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/f2/8d2dd8276ca05e1f5157b6a0d34efb2f585f47a0fbed61e8aad04b221f0b/bidict-0.24.1.tar.gz", hash = "sha256:4dca6c17f0b01700e9f24359daa5ebabf7be022d99f4cb2a257b6af2a5076c88", size = 30818, upload-time = "2026-08-25T23:45:52.214Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/53/2a3c7d562271ec6b6e38e7216b3104899f8aa4180c0713cb8aaf69e29cd5/bidict-0.24.1-py3-none-any.whl", hash = "sha256:fd3eaa737917d8a14f4baa391670c433c4e3f6f5fd2cd99d4bf436437f432364", size = 36175, upload-time = "2026-08-25T23:45:51.096Z" }, +] + [[package]] name = "boto3" version = "1.43.102" @@ -878,6 +899,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] +[[package]] +name = "chardet" +version = "7.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/cd61c567092a6cec796144510a68aff158ebfc1df82950a45bae65f28413/chardet-7.6.0.tar.gz", hash = "sha256:93d9df6089ded42ed1fe9f57e272c0b74bd0464d45c0c7d50f09f26f31105c3c", size = 914462, upload-time = "2026-08-14T20:36:59.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/62/64da80dad0c804e743b4156f379183578f1e33918856ae928dc9248a6002/chardet-7.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:19fea52164e6e00f2a21ed418f42e4b0162a09199274c86d07ad3efd661317c4", size = 1094073, upload-time = "2026-08-14T20:36:26.53Z" }, + { url = "https://files.pythonhosted.org/packages/44/99/934fb862d102c8756008597f4398323f32cef329f16e87fbb3bf76d4f4be/chardet-7.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a12023d48d0e207791c01161d03cb3c0d85c6a15f345eb9d3d56063a63d1e40f", size = 1071612, upload-time = "2026-08-14T20:36:28.067Z" }, + { url = "https://files.pythonhosted.org/packages/71/e9/b04e0ec576a77e79fe37279a9a5d5b1ae752d365e43df2eca0d0eee4cea5/chardet-7.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:249993b88ac7a58cad2781acea8f379152a28a719c9b401d614898c63a8c83da", size = 1489219, upload-time = "2026-08-14T20:36:29.357Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a2/c4d99299e9ce7fad561f8bb56babbbbdd3bb6b4fbd7c0ec674c1dbdd2cc5/chardet-7.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf0adaca8b1c4bacfade9d0a1e4f8f70b1bb122833d6f07ab90e3adc84eb13a", size = 1518293, upload-time = "2026-08-14T20:36:30.879Z" }, + { url = "https://files.pythonhosted.org/packages/56/1d/49f13052b74303bab2789d098063cbd19758217949ea54ffa216b6098cb3/chardet-7.6.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cf6d08c2373b7772a558d141f9e8cee53fe1d222341bac612e4d558b04995f73", size = 1459077, upload-time = "2026-08-14T20:36:32.149Z" }, + { url = "https://files.pythonhosted.org/packages/0d/53/8da1f4758286efd8faf71356facddb382788ecf1bbd7c70d63e2e18a4898/chardet-7.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:406936df1328a3284fef366eaa2bfd1cccd0ef1b10cb99781dd5b022ea644b84", size = 1160778, upload-time = "2026-08-14T20:36:33.436Z" }, + { url = "https://files.pythonhosted.org/packages/a3/29/16a7419edfbd60e901e6a797cbc3e038cb2a81903bc16c029db755f0156f/chardet-7.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:57e6846cc13ce1ff59979f4ec9da770c57e12aa99046073f632de5a51d9a6f20", size = 1088449, upload-time = "2026-08-14T20:36:34.723Z" }, + { url = "https://files.pythonhosted.org/packages/1d/36/3a14b0f8ddeb302f157281ca656a3ce6874b78e2d6af03682f520b487245/chardet-7.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:089e3bb81a0a07e94f15461ded9f9ee66d349615b1a9fd557d4de1003e2fc12e", size = 1065126, upload-time = "2026-08-14T20:36:36.21Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4c/f59a39c2bfe4ac99baba8da842e8d2ea0b84dff7ba53a96e7ad8c71602d7/chardet-7.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:43ea433e43a23c55e8e17f3fad1e07f5cfe5450c73124b95b0d849c21ad379ee", size = 1482492, upload-time = "2026-08-14T20:36:37.649Z" }, + { url = "https://files.pythonhosted.org/packages/bd/eb/93e8036681157f2217a18769927a035984526e6dbd5e91f28a375ca41c14/chardet-7.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b5d31f9b7f793e15e81cca877e7ccd72bffffa2a3443a9d47be9dfee84fad69", size = 1514185, upload-time = "2026-08-14T20:36:39.119Z" }, + { url = "https://files.pythonhosted.org/packages/10/04/0066d7ab2c135e404a6fa166bb7fa49d1c7bf7af07b86ed95b1c48a348c7/chardet-7.6.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c54b6a8d3b219560fa5cf4c28df932c37471afe047afdc152067104e741f38c1", size = 1454420, upload-time = "2026-08-14T20:36:40.549Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9f/e965f1d9eddfb86cf118f980d329cbee43c4ac4b562448b369a6b6ef36af/chardet-7.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:b3b4c96c4df93899b3c8b9e8159e06b1f55c66d7ca384d91481108e251a06eb0", size = 1159067, upload-time = "2026-08-14T20:36:41.816Z" }, + { url = "https://files.pythonhosted.org/packages/cf/78/e14991c9487277ed7d006fff58f3084ac792ed94cced081788abb95df70c/chardet-7.6.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c6061adf247ab5dda173b67010e13904c6071717660c7c8077fb50aca362b264", size = 1087678, upload-time = "2026-08-14T20:36:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/7a/40/0f95e04cb1820e0a582cd6d86bbf26be8302a94ccf330f8ba5f69735389d/chardet-7.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fc1e1571321baf8927582fe34363ad7f02279f11c8c2839c14b4c76894148db6", size = 1065310, upload-time = "2026-08-14T20:36:44.489Z" }, + { url = "https://files.pythonhosted.org/packages/3b/a1/04404dcc9d6e1253b02583e562d2c7ddca50a7e91f460d62eff7e1d07c92/chardet-7.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8900f6c7cf6b015b17a51767cc6144689059ba1cdceaa383d29eb037ac28579e", size = 1485028, upload-time = "2026-08-14T20:36:45.797Z" }, + { url = "https://files.pythonhosted.org/packages/f4/88/360064c4c7d9d0664561dae03b74c871d2f5332b329f5c99f1c997fb869a/chardet-7.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cedbc584789eb2edfde20fd03669972a833ce6019e60014ae613f9bfc440e8e3", size = 1510242, upload-time = "2026-08-14T20:36:47.206Z" }, + { url = "https://files.pythonhosted.org/packages/43/4c/302869fa1c69a5a41e4e78782680b12552ffd4286c3ae3c839b7b8df53d4/chardet-7.6.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5dc835e40e0e09c2c3eab43731a8b5127834f42786dda09ba2f4b699ccd527a", size = 1456456, upload-time = "2026-08-14T20:36:48.668Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a9/ff4fef15ed25fc3f945a3b981ae0f43c8559b3fbedb40267e59e583d105b/chardet-7.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:0f304de7041afaec0195ad6464937cd112392002e9d72ed15d55f20a9abd3a13", size = 1159103, upload-time = "2026-08-14T20:36:50.107Z" }, + { url = "https://files.pythonhosted.org/packages/31/44/94e6f89485ce6c630e8fec6d388e3fa747e58b7246b0cc8c5c532ba98650/chardet-7.6.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a4f0a368ad04d5def08bdfaa17c7e15e71552f93923dc2aa9b2f7d9dee02fbb6", size = 1120302, upload-time = "2026-08-14T20:36:51.484Z" }, + { url = "https://files.pythonhosted.org/packages/e0/cd/8e68ba12f13aaf4ee82d54ef8a1f83d62942e7a6bc1e579dd2d530a3e26e/chardet-7.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:75d6c3a4d2046d49e83d2d2206eb073a1f390743e856d90c1bbc19949b26acf4", size = 1093157, upload-time = "2026-08-14T20:36:52.987Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f6/6a35342b9efa69dcceab0ea8966571c6442a59c336bf460f0a2af95ed234/chardet-7.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:459e2b1c98f9a86a4698112aa42dffa802bbbff883c1ff144071f87224125862", size = 1521213, upload-time = "2026-08-14T20:36:54.812Z" }, + { url = "https://files.pythonhosted.org/packages/df/8c/8f09bfabbaedae45caa72b996e96d5e3f6436dfddc55c1311ffa2f6bd7d2/chardet-7.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bdb6f03107b7ace3f44e0edd91aa24456ee558787df265cc19daf45785b31c7", size = 1528427, upload-time = "2026-08-14T20:36:56.224Z" }, + { url = "https://files.pythonhosted.org/packages/0c/3d/2540627b193112e08b8045f3cad9295570866208555aa6625b63cc69c6cd/chardet-7.6.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:0c44a32da32cc8b23d6b20d98ace15ec7600950e4955d1bf5ab1f849b0187fdb", size = 1087374, upload-time = "2026-08-14T21:04:14.994Z" }, + { url = "https://files.pythonhosted.org/packages/fe/de/dac4f550cde73c4732b4916e72ec489ae36933fbbd1697e4122d3ae2e9dd/chardet-7.6.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7bbc8a9652c7f859c593847f220c1d264f25749369abb1a267b404ee8cceb209", size = 1064688, upload-time = "2026-08-14T21:04:16.806Z" }, + { url = "https://files.pythonhosted.org/packages/06/45/f4f3f288496797d2cf1d1e9036f920ed2b4c79787b21c42a3314f4a6d532/chardet-7.6.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83512a475a2f3886166aa0bca1bbb39343a4eb3186dd5532127d6f2591d09118", size = 1486320, upload-time = "2026-08-14T21:04:18.535Z" }, + { url = "https://files.pythonhosted.org/packages/c0/81/3ca30c16e6c6015b737fca22d9342957cc617d8a65329d3e963ad754a31b/chardet-7.6.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:271ab71ec1be61dbbce0436de0848895c03eae051c379e937a39573d9ce403d9", size = 1515374, upload-time = "2026-08-14T21:04:20.724Z" }, + { url = "https://files.pythonhosted.org/packages/6e/98/163a75b8b3372a6b6503f5f5a4154a222ac135c7a706b5d176f8e4b237a2/chardet-7.6.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da86fc1b40ff5996fbb5e4c2d2dca770eac2c893cef157dacc050b8b4d929846", size = 1469504, upload-time = "2026-08-14T21:04:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/d7/4f/3ad1b1bd27ae7f0d3d13cf711366525cd781a9e067950d8a09aa280916cb/chardet-7.6.0-cp315-cp315-win_amd64.whl", hash = "sha256:b73f277c1ac09c4f8076c4214b816c7aa78a0a2f0cb7156742f4303f856bedc3", size = 1158551, upload-time = "2026-08-14T21:04:24.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/92/22a609c68c5123ebdccd8fe1a80e423609012ce20166de19a2ed3955b2f7/chardet-7.6.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:61238d5945b36af9a2ad13494f8969b7deb3c3b4abe223e54670c064e73f5328", size = 1119186, upload-time = "2026-08-14T21:04:25.761Z" }, + { url = "https://files.pythonhosted.org/packages/29/35/75a90143e4200e197f4f6cf5895d379e22bc785d0c7711120df18fa5347c/chardet-7.6.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:f2ec3c78cc6b54bf8e091ec4ee885473078b5d7ef18ab1b01c86ae1e98bf88f7", size = 1092596, upload-time = "2026-08-14T21:04:27.884Z" }, + { url = "https://files.pythonhosted.org/packages/d1/2c/b6d5f47d878c46e04ae6fcb58e0969467925bc0819b333c682e0c41bbc5c/chardet-7.6.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:167d7ba3ee08b654e36d7b43ebd9a36606c9a12e2fabdb361757a095ca3b7e3d", size = 1516644, upload-time = "2026-08-14T21:04:29.624Z" }, + { url = "https://files.pythonhosted.org/packages/25/d2/2bc3f1066c6f27bc3fa3a98dfd8a2a9c1e969a9d6bdc1edcd35278791fc4/chardet-7.6.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f14f46ef1977e41ce1f4814ca6984cea7f8b6baf8cbc6626ef7bf3d13cf7ea13", size = 1533221, upload-time = "2026-08-14T21:04:31.39Z" }, + { url = "https://files.pythonhosted.org/packages/cf/6e/5a0b348fa4cd7847567a28c6e697ccf58391960bfd13a6e7473ee23ca2f2/chardet-7.6.0-py3-none-any.whl", hash = "sha256:4076d795897ce45239825956a1334e134322ecc4bfe84dbb12acd5390de0fbc1", size = 680279, upload-time = "2026-08-14T20:36:57.763Z" }, +] + [[package]] name = "charset-normalizer" version = "3.5.1" @@ -1263,6 +1325,134 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/5f/63f927b5ffd1cc2c030fc2bd362bf920890e8806cf42f701198de5b3f9c7/dateparser-1.4.3-py3-none-any.whl", hash = "sha256:cbce86c64e0cea5c54c84c015d34c903d46c51bfad50632bf2f05a6141d10c05", size = 322384, upload-time = "2026-09-03T10:07:52.913Z" }, ] +[[package]] +name = "daytona" +version = "0.220.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "aiohttp" }, + { name = "daytona-analytics-api-client" }, + { name = "daytona-analytics-api-client-async" }, + { name = "daytona-api-client" }, + { name = "daytona-api-client-async" }, + { name = "daytona-toolbox-api-client" }, + { name = "daytona-toolbox-api-client-async" }, + { name = "deprecated" }, + { name = "httpx" }, + { name = "httpx-ws" }, + { name = "obstore" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation-aiohttp-client" }, + { name = "opentelemetry-sdk" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "python-multipart" }, + { name = "python-socketio", extra = ["asyncio-client", "client"] }, + { name = "toml" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/b0/cfedb592b33bfbe9ff4f436415ae358f9a2b96dd9e5b17fab404741bf5b7/daytona-0.220.0.tar.gz", hash = "sha256:eda82eac9b3601e757b1dbe3ec2d966b46a9e3038b69ce44a0d90e01dac8417a", size = 193994, upload-time = "2026-09-29T11:34:58.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/59/13c06049d30bca67929aa60b0acf6470fc18de283f429c0f520d20532495/daytona-0.220.0-py3-none-any.whl", hash = "sha256:5f71299eaf3a446e6569ada69919fc49997c1b2c587845057394aab886f113c8", size = 231416, upload-time = "2026-09-29T11:34:59.757Z" }, +] + +[[package]] +name = "daytona-analytics-api-client" +version = "0.220.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/18/f9b5fbdd4b79df6f35e6365a356c2f7c99cdde3d173ccd1af7524226ed9f/daytona_analytics_api_client-0.220.0.tar.gz", hash = "sha256:38c6f8f0d1ab6af77e18b0a9b0cacade500c7a31312825497ccb5dc87b278e06", size = 30104, upload-time = "2026-09-29T11:34:18.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/b7/b2f362625694b306f635fc57110215f09743905f95fb8a0ba89eeee6278e/daytona_analytics_api_client-0.220.0-py3-none-any.whl", hash = "sha256:5fa3330db06093cb6d993e4d7da1c1a89c934afff57c80ba1949286af9a9af2a", size = 45011, upload-time = "2026-09-29T11:34:19.927Z" }, +] + +[[package]] +name = "daytona-analytics-api-client-async" +version = "0.220.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aiohttp-retry" }, + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/d4/9be90315b747bdd0989c65dcde92bf88bf901579325ab9498ad6e8f3c288/daytona_analytics_api_client_async-0.220.0.tar.gz", hash = "sha256:0b23aafb9ff271841fe12eeb47a70c3f39a68ee9b9195bd7db3fa195fc30ac93", size = 30135, upload-time = "2026-09-29T11:34:22.354Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/c8/68c2086b4adcd936cba2a05dbbb774285477ae9bd7fb7b6be325cd3b3725/daytona_analytics_api_client_async-0.220.0-py3-none-any.whl", hash = "sha256:971487b3ac2f7cde40784c3b05c4e4d478411af2e61922a362fb2f17a31dda77", size = 45284, upload-time = "2026-09-29T11:34:23.677Z" }, +] + +[[package]] +name = "daytona-api-client" +version = "0.220.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/5b/e6bd0a5dd404d2e528fcb8880ad8a858d47976f9108c4e48956f1c974ffe/daytona_api_client-0.220.0.tar.gz", hash = "sha256:b33d59f83399c5df14cf7e29ca1fbd5e1c770ff9e97a29936eb0a405791db258", size = 135892, upload-time = "2026-09-29T11:34:15.799Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/b1/711e40f91bdf7e51daef0e04333fcc454c3995999a718ae3dfc1a24e2236/daytona_api_client-0.220.0-py3-none-any.whl", hash = "sha256:f0195262f33fe2e6b5df8b33b3ce87e7d6b8c2ed9efec830bf37ab09662838d6", size = 349122, upload-time = "2026-09-29T11:34:16.972Z" }, +] + +[[package]] +name = "daytona-api-client-async" +version = "0.220.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aiohttp-retry" }, + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/3f/e6ae44d4f1183ac275600214bca813db94762de325cc2f0d2c39e945b525/daytona_api_client_async-0.220.0.tar.gz", hash = "sha256:44688d5e5be3275831bcfa1f3a6ba20553d960a7c6d8a71b71a51e807cfdd4fe", size = 136232, upload-time = "2026-09-29T11:34:16.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/c6/1669cfbf3b7ed301a6cc536e7eb3f9667407024ea31c0f78a1015ea995e8/daytona_api_client_async-0.220.0-py3-none-any.whl", hash = "sha256:b8e058f71809a3de3047944d4dae5671459884d3ae82a1d7943cdfaab57e4dc6", size = 351917, upload-time = "2026-09-29T11:34:19.518Z" }, +] + +[[package]] +name = "daytona-toolbox-api-client" +version = "0.220.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/f5/bf3c40563ec6d1cc94bd18bba7b211b078d72796bbd15d446bb5c85ca02b/daytona_toolbox_api_client-0.220.0.tar.gz", hash = "sha256:945cdc6f3290821128cb3477ae723c41d0a213327eccd5539a337a7d1e97df86", size = 88159, upload-time = "2026-09-29T11:34:15.96Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/cb/28c89154bb475ba02b626ee052169b0846f22aec8d39d0e2c33b21af014a/daytona_toolbox_api_client-0.220.0-py3-none-any.whl", hash = "sha256:39a31d7e0aa5052cccab6fe86ed9f9568d73af598e699e0886fbc65926e8774f", size = 252415, upload-time = "2026-09-29T11:34:17.974Z" }, +] + +[[package]] +name = "daytona-toolbox-api-client-async" +version = "0.220.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aiohttp-retry" }, + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/27/98a21dad9ea18817a8d84d5e7e610e454061f28e04c8037a9c3bc8fa68b8/daytona_toolbox_api_client_async-0.220.0.tar.gz", hash = "sha256:ade1ed0254323687db3b5af4ff4569108a0ea85c28584dcc39a34b492b267d5b", size = 82030, upload-time = "2026-09-29T11:34:21.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/8d/a5d8f2d78af02e498217ef4784d032e50e7444b891b851722d89b67e948b/daytona_toolbox_api_client_async-0.220.0-py3-none-any.whl", hash = "sha256:aa2a354de63364a8529ed7bf6e6c01bc79cd78658b5c106f27280155a266a9b4", size = 250905, upload-time = "2026-09-29T11:34:23.076Z" }, +] + [[package]] name = "ddgs" version = "9.16.0" @@ -1295,6 +1485,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + [[package]] name = "deptry" version = "0.25.1" @@ -1622,15 +1824,15 @@ wheels = [ [[package]] name = "genai-prices" -version = "0.1.7" +version = "0.1.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx2" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b5/62/5f294af24a7ae407e6fa1a7ff36d310bed36b89ea6cea7eb7cfac5c305ff/genai_prices-0.1.7.tar.gz", hash = "sha256:4305395c5891796860c697cc3a49ab50b751ed62c8ff1911e291ea8da77f3a91", size = 114996, upload-time = "2026-09-15T11:24:10.852Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/f0/4b4c7f63de6d558714d2207808080dd0988d0317a54ebcde790e4f6c1b6e/genai_prices-0.1.9.tar.gz", hash = "sha256:20ec774d32ec5d345510877fac415f2560bbfeb081bc4cd4e7ebce57a5503e8d", size = 118447, upload-time = "2026-09-25T03:47:27.569Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/4a/9e04f3dca5ffe74133779ab5e9925ffb6411afbeac55d26fa0e148722599/genai_prices-0.1.7-py3-none-any.whl", hash = "sha256:090ba285549e0d2ea3187d05f091a5f0ae722e652143fa844ac809205aecd023", size = 121970, upload-time = "2026-09-15T11:24:09.54Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e0/57d07f653813b7ea9e3a82064a2b6a112f63004530ac665e0934829473c7/genai_prices-0.1.9-py3-none-any.whl", hash = "sha256:3da8faa0a126e4df7be36696b90abe2be1fea8826bfcf45d39d91bc397fa9d1d", size = 125549, upload-time = "2026-09-25T03:47:26.483Z" }, ] [[package]] @@ -1711,7 +1913,7 @@ wheels = [ [[package]] name = "google-genai" -version = "2.23.0" +version = "2.28.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1725,9 +1927,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ae/9a/db14adffb0144584889e05f433a4e8ce540f9ed2928ed8b23cc65ab96178/google_genai-2.23.0.tar.gz", hash = "sha256:1ceebffdcd2af30c039a922ba05de61cd4054bb61c94be7649c4bf9ff8b33c5f", size = 686707, upload-time = "2026-09-10T22:55:31.12Z" } +sdist = { url = "https://files.pythonhosted.org/packages/59/16/4af15d65cb72ebc21a99c5e64f09072e97334c05a48d4cede19c347e6ad2/google_genai-2.28.0.tar.gz", hash = "sha256:970cb2eaf1951949851c3e0a5b70265cfea8a408fb7361c7c980a84d90672bf2", size = 707359, upload-time = "2026-10-02T17:58:14.454Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/b0/f6bc58a6a7c8ad7ad2b6a36b0bf4732b3504dd7d48c6674f848304548869/google_genai-2.23.0-py3-none-any.whl", hash = "sha256:1e63211d44d188b8069c2b354d92b9bde25c1e821513fdbe1948b7c0d9f6b922", size = 1093785, upload-time = "2026-09-10T22:55:29.282Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/47278c34757293ba8d9156090d38e1c45fac9a47c50176acff7f77ae9a66/google_genai-2.28.0-py3-none-any.whl", hash = "sha256:cdee15e2fbfea08ea61bce6e5bfbb3301db4bd13a52dccfa8c8d1167e37ca1cc", size = 1168554, upload-time = "2026-10-02T17:58:12.585Z" }, ] [[package]] @@ -2101,6 +2303,21 @@ http2 = [ { name = "h2" }, ] +[[package]] +name = "httpx-ws" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore" }, + { name = "httpx" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/cd/ca91a07ae446451f7476bf3fcc909e98cb942ff032ebfda0e3fe449aca7b/httpx_ws-0.9.0.tar.gz", hash = "sha256:797373326f70eec1ae96f6e43ae9f12002fd7d73aee139a4985eaab964338a08", size = 107105, upload-time = "2026-03-28T14:11:10.781Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/f8/a6bc80313a9e93c888fa10534dfce2ad76ff86911b6f485777ce6de6a073/httpx_ws-0.9.0-py3-none-any.whl", hash = "sha256:71640d2fb1bf9a225775015b33cd755cfd4c5f7e21c885192fe3adc4c387b248", size = 15759, upload-time = "2026-03-28T14:11:11.887Z" }, +] + [[package]] name = "httpx2" version = "2.13.1" @@ -3256,6 +3473,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/f4/78229a1066068ca14fc60fb26cf7381cabe4382261392b90e5f9552722d4/oauthlib-4.0.0-py3-none-any.whl", hash = "sha256:624c28c13a0a59cabf9747dfa52af63be3e512a7f2714df16e91b5b3a145e6cd", size = 159715, upload-time = "2026-09-28T06:01:17.008Z" }, ] +[[package]] +name = "obstore" +version = "0.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/27/aa7549157a4a681157e315534ba5ab8f167f77662166e792a6e836938f46/obstore-0.11.1.tar.gz", hash = "sha256:a5afe8b99e3b20cdc9133be7a1b381259acf0d470029f6b2fc79c3f9947ad436", size = 130828, upload-time = "2026-08-21T23:57:27.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/38/438f85772bfbcd2985845a5d00d1c910e98b15f587dae6cb1e435865eba9/obstore-0.11.1-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d5c50b755d781efebe4f9c70ee6d00858e44d95f0229b8b5174358f861d9bd7c", size = 5400146, upload-time = "2026-08-21T23:56:29.168Z" }, + { url = "https://files.pythonhosted.org/packages/e7/62/edd2613649cb6b4400f5d125223ee094d2da1c4c93636453a5360c61b865/obstore-0.11.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:093152daa5c32b70a032f231bbc6a7eff76dda4285eed5a81d272b4485032dab", size = 4596203, upload-time = "2026-08-21T23:56:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/c1/52/09edd48251a26a65f786bf2def93994ffce501f3aea6724474d8de0a7f4f/obstore-0.11.1-cp311-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b97ee10456e65f166c030b5fbde8380ca508621e23b84efd77aad83afe99315a", size = 5003021, upload-time = "2026-08-21T23:56:32.085Z" }, + { url = "https://files.pythonhosted.org/packages/59/8f/89a28c75d46bbb0552d34a5e84c5bc512526d45f935321f00af2e6275a99/obstore-0.11.1-cp311-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:48983a143de69b11de49212caa79b5c39acbff7f592f9e85d156a0133a0e5796", size = 5240376, upload-time = "2026-08-21T23:56:33.658Z" }, + { url = "https://files.pythonhosted.org/packages/50/c3/b4620899003e2472bec7baf5d2bce12cf6f6f11d02d8f17e03e093717a53/obstore-0.11.1-cp311-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b9cb988e03ff963914cf2176aee293eaaff7dcf4efcb905ce6834264b4b0884", size = 5444946, upload-time = "2026-08-21T23:56:35.093Z" }, + { url = "https://files.pythonhosted.org/packages/bd/de/687685ab39ae4a70cc13ac252febfe62a387836a55e046f467c9fb231880/obstore-0.11.1-cp311-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97850f68c8417f7167549bdb705c362c825ed470298c6b04faf52258c5e9234e", size = 5304065, upload-time = "2026-08-21T23:56:36.535Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/b962ad52031b5bfdd249923db6151ff2b665b08c017611db96838455f337/obstore-0.11.1-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a9f3d66dbf3c073dd6b6033c0787ef14edf78d97bff95595d6f6979692da264", size = 5556770, upload-time = "2026-08-21T23:56:38.377Z" }, + { url = "https://files.pythonhosted.org/packages/e0/04/41a0f2917fcaa1e66b754fc98c92441d5a252ff9c619941ac086f9bdb4f6/obstore-0.11.1-cp311-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:2ba3bceec4263b3a70eea873abedb82e03da9599f2326e80d6505cab0c10d401", size = 5337765, upload-time = "2026-08-21T23:56:40.279Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/0b618efe59ee4b58f16d2b9246674a70c390bebf665cd284b439bade9bd3/obstore-0.11.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e956fa8a953b7eb8580658d68cdf37e410356032c17697a06e44d0e8c4084a0f", size = 5544012, upload-time = "2026-08-21T23:56:41.754Z" }, + { url = "https://files.pythonhosted.org/packages/57/3b/1018f6da240f3529d1cb9a836c79a91ce45349ba3cffcd6baa73296c7e6c/obstore-0.11.1-cp311-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:159a50d0f4cc53afe6f5c695313bcaf6e92ac81d7847692c8279153483272bfa", size = 5229851, upload-time = "2026-08-21T23:56:43.367Z" }, + { url = "https://files.pythonhosted.org/packages/35/2c/c2fe082816b255159373b15be3f28b2515d0eb954e248e2f65449c456a14/obstore-0.11.1-cp311-abi3-musllinux_1_2_i686.whl", hash = "sha256:128da07f3a1b9c70159e2b2be9e27f458a9d531d57b1856dd7c4ddb73b4c9937", size = 5361812, upload-time = "2026-08-21T23:56:44.871Z" }, + { url = "https://files.pythonhosted.org/packages/43/9c/656eb5cce818e7e3985d27f4bd403feb0b6e620494775a55a0943c8d2fff/obstore-0.11.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:67b8acfbbab960c2cb14c34294a96556caa67fcff15ee77c716d5c1bd1558606", size = 5790855, upload-time = "2026-08-21T23:56:46.391Z" }, + { url = "https://files.pythonhosted.org/packages/c1/5c/94ece1c902ff5cdbe1140997d1f720221a0d5defa902fdd93715297604f5/obstore-0.11.1-cp311-abi3-win_amd64.whl", hash = "sha256:e23ea15cebe5f5be5d11005043d7b5ff56848e39499681ef52f8227bd385bd51", size = 5308846, upload-time = "2026-08-21T23:56:47.774Z" }, + { url = "https://files.pythonhosted.org/packages/c0/af/235730a429e55777f15f9d7fef118a6ecf02f430ed4588deb4b7e13964aa/obstore-0.11.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:73284fc8a9804596baf4d80b55c0e71a6007487bfa28d6924acd85264d5be81f", size = 5424585, upload-time = "2026-08-21T23:56:49.532Z" }, + { url = "https://files.pythonhosted.org/packages/e3/3c/432ea70c061fc9401fe399391ed4f04dead4a4cf33ea7c9564ce0e299d24/obstore-0.11.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8a3f93310422b153629af929d9e88d1fc826d5e32a456de4d3a1926a770ae09c", size = 4576791, upload-time = "2026-08-21T23:56:51.014Z" }, + { url = "https://files.pythonhosted.org/packages/43/c7/57dbdfa4ba5339b80fc755359bb232bdba7f82e92df6603ef383396b5f16/obstore-0.11.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d666690f0a53ae3df4be2c18af6820369a74c9c5b8960e8760f0f8c9a8f15b36", size = 4992141, upload-time = "2026-08-21T23:56:52.46Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/d25a4c129250364b0ca927fbfca5146744fb3bb4de60ae9b302300cbdfef/obstore-0.11.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0844ab75c8413c0af2d0fd2d2f6aa42d166809b6ac4be478151cd946ce7e5d69", size = 5217707, upload-time = "2026-08-21T23:56:53.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/0a925c886639d6edce37aafb6eba0c86820cbc9dda42e91ddd784dbe059d/obstore-0.11.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:307b5f9d64a7c00cd13371e376c05e3914216bd0818a19ff2ae05bc0135e01dc", size = 5429440, upload-time = "2026-08-21T23:56:55.507Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c0/38d188a00b9f2de30df26802dd5bc2ecaefdabb882fde786cff800ccf50f/obstore-0.11.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f89647953b4ea50bab3f66591f7f462cd454a5d2fbbeefd885716a2c54e13ce", size = 5308903, upload-time = "2026-08-21T23:56:56.924Z" }, + { url = "https://files.pythonhosted.org/packages/60/d3/815e5a7ca59f901b6d24fb01ad3af46de37dfdfd2971c019f914bdb7f65e/obstore-0.11.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50de3116af69b6f1669cb443cc200ccdc1523a790a41f00854331b48f716cf8f", size = 5547007, upload-time = "2026-08-21T23:56:58.508Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a1/ec4d2291b7eb15e7ddccb2f0646ce560b8a539df4b0ab94d916e7a15a61c/obstore-0.11.1-cp314-cp314t-manylinux_2_24_aarch64.whl", hash = "sha256:eae71e1c5944ade976ce8cd1e780bed9b5c31d6d4f89cb7263e8dcff78f6cd8e", size = 5330062, upload-time = "2026-08-21T23:56:59.954Z" }, + { url = "https://files.pythonhosted.org/packages/0a/09/a4a1301f2474f6569bbc0f2d337108ff2225d0b3d285894ed9a6dc953438/obstore-0.11.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff634b5edbbf76c56ae81397aa41500a66ad0aa48b1856dcc0cf31b159172dc0", size = 5539870, upload-time = "2026-08-21T23:57:01.485Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8f/7dbbf935a07ff5375c33916307b77aef9d39bded35081530150822898010/obstore-0.11.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cbe509350d66249fc9e65ece4e7b1855d650f18e477721887128452b95c44b24", size = 5222673, upload-time = "2026-08-21T23:57:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/d5/89/4932b3dd7963a3a64fd532c30ccc836f0f4ede1a45bea9f12295299398e2/obstore-0.11.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:46122e585f48ab3f2e4fed51401a4e866279a3a3d1aee2372d598ab85ba2c539", size = 5335696, upload-time = "2026-08-21T23:57:04.439Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ff/f3cf31ac4ebfa648dbd2c69774579fd5da4cf4b05fa1192405dafb6aeb38/obstore-0.11.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7275e75228059b2b30772b2ba3e41562a4a92ebdd3a96619d300f98ef152119f", size = 5783641, upload-time = "2026-08-21T23:57:05.899Z" }, + { url = "https://files.pythonhosted.org/packages/51/31/b352db3450e700c2c51861d139fc92c3284f7e578ca306408b054f6b4a35/obstore-0.11.1-cp314-cp314t-win_amd64.whl", hash = "sha256:13bb0b6a40931ab2da93f93ad843865d483d95acec1bd586df79313daa5a50af", size = 5290060, upload-time = "2026-08-21T23:57:07.632Z" }, +] + [[package]] name = "odfpy" version = "1.4.1" @@ -3276,7 +3530,7 @@ wheels = [ [[package]] name = "openai" -version = "3.14.0" +version = "3.24.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -3286,9 +3540,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d0/88/472211fbaee888238d2e0657bcfdba5f64f3d38899dbfdada5e632a9758f/openai-3.14.0.tar.gz", hash = "sha256:714ba70b91ee1e78e75263694bebfeaca004432032685333a46d0e0206114bf9", size = 1635622, upload-time = "2026-09-14T23:29:10.961Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/4f/e57670227cb7b61362d8f9bfba54d4e9f7bde799798c342782788bc12d6c/openai-3.24.0.tar.gz", hash = "sha256:1e7463f7d78773ab2ce4fe85710481aa5bd5ffefd54c8de4b067506cd2d42895", size = 1797218, upload-time = "2026-10-02T15:55:18.851Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/e9/53504fbb21b2b765f77f972d34f86a80e5b98ec274b503a8e3d557df2aac/openai-3.14.0-py3-none-any.whl", hash = "sha256:232a85a1c0ff6820534630fbfdb99313990ed80e66f39892a3abcff4130e2b16", size = 2010880, upload-time = "2026-09-14T23:29:08.978Z" }, + { url = "https://files.pythonhosted.org/packages/f2/01/32baec3bdb83d36da27281d7eeac2647241345cf8ff538bc21d1013ddeec/openai-3.24.0-py3-none-any.whl", hash = "sha256:b59703a3e3b5df78b269e90049d383a1dda58c90104cc4be59db21078db4b7a4", size = 2127166, upload-time = "2026-10-02T15:55:16.228Z" }, ] [[package]] @@ -3360,6 +3614,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/7b/85eab1215f72adf0e68d3dc4a679b9bff993fa679ff34cd8dd378e2659fd/opentelemetry_instrumentation-0.65b0-py3-none-any.whl", hash = "sha256:ea967a72b9939b5fcfdad572753b4306c59dcb99e3f382d95dae04286805e137", size = 36717, upload-time = "2026-07-16T15:24:51.424Z" }, ] +[[package]] +name = "opentelemetry-instrumentation-aiohttp-client" +version = "0.65b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/33/3ff7230b035e8b696db6be54f5c52dfa409829d634d91431c076ad789820/opentelemetry_instrumentation_aiohttp_client-0.65b0.tar.gz", hash = "sha256:85906a2806ee5641756b5c33274e9aa75c3cc2441e3b830aa5804cf0e1fa9dd1", size = 19042, upload-time = "2026-07-16T15:25:51.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/f9/5c8459224f175829601cabbcffaeab0f9041903c086e4a0368f9971093a5/opentelemetry_instrumentation_aiohttp_client-0.65b0-py3-none-any.whl", hash = "sha256:3a060efa53fa44d02ba7372a7ed2b42cdfa6be6df81b089845067ad840e25729", size = 13677, upload-time = "2026-07-16T15:24:53.361Z" }, +] + [[package]] name = "opentelemetry-instrumentation-asgi" version = "0.65b0" @@ -4145,28 +4415,35 @@ email = [ [[package]] name = "pydantic-ai-backend" -version = "0.2.29" +version = "0.2.32" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "wcmatch" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/76/6c5901e90cc93d8f9ffbe7a407bf6272be836c75ec7755f29d80e7517b25/pydantic_ai_backend-0.2.29.tar.gz", hash = "sha256:acf2abd1aceeb40cfd9f1a2076123df0bce19c82a45288fd1cdde60b831acbec", size = 15183256, upload-time = "2026-08-22T10:22:36.931Z" } +sdist = { url = "https://files.pythonhosted.org/packages/62/48/a484fd9deb9cfb37a3271135e39a4cd19706f68eed926fb1043c64d73a25/pydantic_ai_backend-0.2.32.tar.gz", hash = "sha256:cae48d6603414e116a7a6a316eb7e5eabfa8a0a581f24b51326b37105780359e", size = 2817861, upload-time = "2026-10-05T21:32:56.83Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/37/604a27c0d3a74ab9c3aeb5063ac0ae40439c428bb50253ba0d17071b3dda/pydantic_ai_backend-0.2.29-py3-none-any.whl", hash = "sha256:d5d2413165ae5bf7923affa8484b6eee2968e60531514ee17b7e1056e9fc7b07", size = 205335, upload-time = "2026-08-22T10:22:35.127Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/d7e7522a01c9fabe8824d61093caec08e1506d54a959fc8a029078a5bd33/pydantic_ai_backend-0.2.32-py3-none-any.whl", hash = "sha256:7b84dcae8ef91801d8714039a1d02a0d2dd0ddf15332f02caf328d9340acbdce", size = 200152, upload-time = "2026-10-05T21:32:55.154Z" }, ] [package.optional-dependencies] console = [ + { name = "chardet" }, { name = "pydantic-ai-slim" }, ] +daytona = [ + { name = "daytona" }, +] remote = [ { name = "httpx" }, ] +workspaces = [ + { name = "pydantic-ai-slim" }, +] [[package]] name = "pydantic-ai-harness" -version = "0.35.0" +version = "0.54.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "genai-prices" }, @@ -4174,9 +4451,9 @@ dependencies = [ { name = "json-repair" }, { name = "pydantic-ai-slim" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/b6/d30279b62971960c2f740d7b8902a1f93f6de98cc787d56185a501a6905f/pydantic_ai_harness-0.35.0.tar.gz", hash = "sha256:4bd643d09c9be349132e4eb2cbe727c8e78a3658f2f6c7a48b6411444cdd2395", size = 3208936, upload-time = "2026-09-25T05:03:26.736Z" } +sdist = { url = "https://files.pythonhosted.org/packages/37/9f/c187608be9d2cca0818c8c8bf7bf7342a4234ff2c7fea67f8887419d1c98/pydantic_ai_harness-0.54.0.tar.gz", hash = "sha256:f17c824544e29ae688d362f3479c45eb196f3094ab463e035a59f273f34aad63", size = 981273, upload-time = "2026-10-03T03:38:50.227Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/3f/7af10428985bee133d68a60439636450456200b4c896c77f04c9a096529b/pydantic_ai_harness-0.35.0-py3-none-any.whl", hash = "sha256:ed1691d1045480bd9192b0dc13df5ae8a48448f2987cebb7344c11e3e319b293", size = 966091, upload-time = "2026-09-25T05:03:24.873Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/e86d8ab59e9dca4da616516d866fc14496e141c2eac68cdd2735623ecb81/pydantic_ai_harness-0.54.0-py3-none-any.whl", hash = "sha256:f25231a3d2c68414456f15851359ff7c2c59735c8bbeeca666ee5953187a4cdd", size = 1221122, upload-time = "2026-10-03T03:38:37.429Z" }, ] [package.optional-dependencies] @@ -4201,7 +4478,7 @@ wheels = [ [[package]] name = "pydantic-ai-slim" -version = "2.45.0" +version = "2.54.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -4213,9 +4490,9 @@ dependencies = [ { name = "pydantic-graph" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/70/ad/d39e46c192e04df2e620871592054185aed4e212558d90bf89436aa7628d/pydantic_ai_slim-2.45.0.tar.gz", hash = "sha256:34cf5216ebe05cb3d343129bb24a1bd011c66e89dd620f00afa982bd2484e3bf", size = 1562650, upload-time = "2026-09-18T04:50:15.315Z" } +sdist = { url = "https://files.pythonhosted.org/packages/54/c0/b5e33b82b05c87a03e2c18089e89671da5c8fb0ba0da143011917d102745/pydantic_ai_slim-2.54.0.tar.gz", hash = "sha256:e39fd5ded83c5f2f1b271f42707de04fba420c4fd2c3bbc809d8d57614b904df", size = 1802066, upload-time = "2026-10-03T03:38:51.982Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/5b/1397c8c65eaeae781ec489db648ab24125268b749d1f256ba7c13b07891e/pydantic_ai_slim-2.45.0-py3-none-any.whl", hash = "sha256:0914f4e307c64401b676bbba2d613da69f74dbeaab4e2e8516d0d9b53f4ab483", size = 1857668, upload-time = "2026-09-18T04:50:07.395Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/93dbb3ba7d13eea6b1c4af02da6a3ab84ed087747b7e801ab675523b3e59/pydantic_ai_slim-2.54.0-py3-none-any.whl", hash = "sha256:b64d2394f1b38d8b02c9a7916ce7bd331cfc25df41372e68d847fd71801fe112", size = 2115426, upload-time = "2026-10-03T03:38:39.601Z" }, ] [package.optional-dependencies] @@ -4347,7 +4624,7 @@ wheels = [ [[package]] name = "pydantic-graph" -version = "2.45.0" +version = "2.54.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -4355,9 +4632,9 @@ dependencies = [ { name = "pydantic" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/56/ee/d4f4c73eb91241af7a06e4c36ef58c18d3622dc03d8343825464ec06cb2e/pydantic_graph-2.45.0.tar.gz", hash = "sha256:c07e8cee55f25d79ea2ea55de67fc67d0353ec2807e3a1328f42995edf022c23", size = 45192, upload-time = "2026-09-18T04:50:17.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/6f/b5cb10c937c8d00a219eb5a07aeb991d0c44d09d5ac31a087cd1ad40dbcd/pydantic_graph-2.54.0.tar.gz", hash = "sha256:948ff7249c28643ff028b47fab22b8a5b36912c59bb947c2a9b9bc18de1d5fa5", size = 45970, upload-time = "2026-10-03T03:38:55.987Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/56/d040149787ae9ade628f05c1e7f30657437731b0e2bb3a2cdb6cb979b7af/pydantic_graph-2.45.0-py3-none-any.whl", hash = "sha256:60fef7eab40a103510f0baf6d17cdd11418672cb64a7524089fbaf845f45aa1a", size = 52701, upload-time = "2026-09-18T04:50:10.72Z" }, + { url = "https://files.pythonhosted.org/packages/6f/31/0d46d4003cb9e452bcf97f56f7c948006b5ad8036fd2ddb6fd7d5c73e8ff/pydantic_graph-2.54.0-py3-none-any.whl", hash = "sha256:465b87d5507af1341ba31b949af8619009c5010068159e52cf9b9806eb674fa4", size = 53423, upload-time = "2026-10-03T03:38:45.385Z" }, ] [[package]] @@ -4653,6 +4930,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, ] +[[package]] +name = "python-engineio" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "simple-websocket" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/65/f8bae11b228647e2e2f45b63dec7448efaddb7cb51f529de1fdba69e63b5/python_engineio-4.14.0.tar.gz", hash = "sha256:eaa1e386baf9c2c7959eef7f9d9165c5ea910c5b392f5316e78d29ed073cb43d", size = 80863, upload-time = "2026-08-30T19:52:01.32Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/de/07cfd386974c2a26a7bde41f2111be29bbfc92b9ea0bb76694415a4a1a78/python_engineio-4.14.0-py3-none-any.whl", hash = "sha256:9f0fe275fb7d67bfc1a632421adf22949fd4843bd9c458c004b0a89cede302a2", size = 60291, upload-time = "2026-08-30T19:51:59.776Z" }, +] + [[package]] name = "python-json-logger" version = "4.2.0" @@ -4698,6 +4987,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" }, ] +[[package]] +name = "python-socketio" +version = "5.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bidict" }, + { name = "python-engineio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/04/8647675c93b5e74a3daa41a2a03930bac0cbdcfcf307900f0441ae6550ba/python_socketio-5.17.0.tar.gz", hash = "sha256:c3bbfc4937dcfea7c4d1b182afa94d4a30335d153987e8f2078b344beacf95a0", size = 134574, upload-time = "2026-09-14T22:51:02.968Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/be/44b558c944bc16618483967ecd3424c578705aa33ceee7df8c1e4ab43ea0/python_socketio-5.17.0-py3-none-any.whl", hash = "sha256:b5826fd2f8aa02e11347816349b74ac6b53e8a4f4e4b1cf1388e1aff19b7f3f4", size = 82548, upload-time = "2026-09-14T22:51:01.405Z" }, +] + +[package.optional-dependencies] +asyncio-client = [ + { name = "aiohttp" }, +] +client = [ + { name = "requests" }, + { name = "websocket-client" }, +] + [[package]] name = "pytz" version = "2026.3.post1" @@ -5292,6 +5603,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "simple-websocket" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300, upload-time = "2024-10-10T22:39:31.412Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -5442,16 +5765,16 @@ wheels = [ [[package]] name = "subagents-pydantic-ai" -version = "0.2.22" +version = "0.2.25" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "pydantic-ai-slim" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0c/e3/375e83ea2ea81749dc5b14b09190f6255636e3e8eb19db022f501be9c9ba/subagents_pydantic_ai-0.2.22.tar.gz", hash = "sha256:df00e4e0c71785b6dcb4f8010f6b7d7386df41ee08da416152d5c84b11e14230", size = 652407, upload-time = "2026-09-15T16:25:13.045Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/16/c9eefb6b6ae56bc6e11423c56c5f42627b58d501c4bca47924396ae039f5/subagents_pydantic_ai-0.2.25.tar.gz", hash = "sha256:17adcbb40f0123e1042e88b832274ad496fbbd301325fb734ddd350c6c9073c6", size = 657956, upload-time = "2026-10-05T10:13:08.379Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/15/6378098a8ca12ce7c5482d636181e0268ec2172dd79f6d17bee13e7a89bb/subagents_pydantic_ai-0.2.22-py3-none-any.whl", hash = "sha256:d523288db9678540b85ace9275615cd09787611ac1a08da77b4133118bcfeffe", size = 84240, upload-time = "2026-09-15T16:25:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/7c/34/dfa2208a59cc8024c93b056a206166c855914ba4e395c76428b96c90d43d/subagents_pydantic_ai-0.2.25-py3-none-any.whl", hash = "sha256:ecbc9d88a50018958c7630a4c9a2b4ff1c88f891e5187efb131e857642ff2e10", size = 85486, upload-time = "2026-10-05T10:13:06.909Z" }, ] [[package]] @@ -6028,6 +6351,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/77/7a02b0f05b3ffcdbef9719ce3ee0b508d6a29b58e95299f1580055671db3/wcmatch-11.0.1-py3-none-any.whl", hash = "sha256:fd149ecddb9f0a88ea780017d6dde17c994e494e7f7303d4e3c9d6251f978f4b", size = 43449, upload-time = "2026-08-14T15:20:39.379Z" }, ] +[[package]] +name = "websocket-client" +version = "1.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/cb/a5abcc2891249f393827c650c6296660ce40374ac22d99ab9aea41f9d2a2/websocket_client-1.9.2.tar.gz", hash = "sha256:0fcb57545848be86992e128218fd96dd87a6769ffdb1a968dff79632b85604d0", size = 84110, upload-time = "2026-08-31T14:08:40.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/d2/cc4dc1271e464942db7ee278baae2daa99ee77cb2af744025c04da585a3e/websocket_client-1.9.2-py3-none-any.whl", hash = "sha256:e1a673830a9c7bfa47b1cd3d5e4178f4c9651d80a4eab02c9c23a1c3ec6250ce", size = 95786, upload-time = "2026-08-31T14:08:39.899Z" }, +] + [[package]] name = "websockets" version = "16.1.1" @@ -6277,6 +6609,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/a2/edcfc8d9a30375791b775715f8501364588f65b494ec4f6930568a19e765/wrapt-2.4.1-py3-none-any.whl", hash = "sha256:1e84ec5d89a0a07a0ef6bcd343f5c8ecdc95601d71de3058cdc63274e86c193c", size = 75317, upload-time = "2026-09-10T23:12:14.82Z" }, ] +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] + [[package]] name = "xai-sdk" version = "1.19.0" diff --git a/docs/architecture.de.md b/docs/architecture.de.md index 7c2da2877..c255f19b1 100644 --- a/docs/architecture.de.md +++ b/docs/architecture.de.md @@ -1,5 +1,5 @@ --- -source_sha: "8d9cf7ac71b1" +source_sha: "d6244bf5e175" --- # Architektur { #architecture } @@ -372,7 +372,7 @@ bloßen Daten: | Resource | Hinterlassen vom Runner | Gelesen von | |---|---|---| -| `WORKSPACE_BACKEND_RESOURCE` | der geöffneten Sandbox-Session | der `sandbox`-Capability | +| `WORKSPACE_RESOURCE` | dem geöffneten Workspace, den der Run auch als `ctx.workspace` erhält | `tool_output_limits` und einem Delegierten, der `sandbox` teilt | | `SUBAGENT_RUNTIME_RESOURCE` | dem aufgelösten Delegationsbaum | der `subagents`-Capability | Delegation ist der schärfste Fall für diese Regel. Ein Delegierter ist eine Zeile; diff --git a/docs/architecture.es.md b/docs/architecture.es.md index b1b431742..60f41405d 100644 --- a/docs/architecture.es.md +++ b/docs/architecture.es.md @@ -1,5 +1,5 @@ --- -source_sha: "8d9cf7ac71b1" +source_sha: "d6244bf5e175" --- # Arquitectura { #architecture } @@ -368,7 +368,7 @@ Dos entradas de ese dict son costuras hacia otros subsistemas y no datos sin má | Recurso | Lo deja el runner | Lo lee | |---|---|---| -| `WORKSPACE_BACKEND_RESOURCE` | la sesión de sandbox abierta | la capability `sandbox` | +| `WORKSPACE_RESOURCE` | el workspace abierto, que el run también recibe como `ctx.workspace` | `tool_output_limits` y un delegado que comparte `sandbox` | | `SUBAGENT_RUNTIME_RESOURCE` | el árbol de delegación resuelto | la capability `subagents` | La delegación es el caso más afilado para la regla. Un delegado es una fila, y diff --git a/docs/architecture.md b/docs/architecture.md index e21adf8e1..13803f162 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -343,7 +343,7 @@ Two entries in that dict are seams to other subsystems rather than plain data: | Resource | Left by the runner | Read by | |---|---|---| -| `WORKSPACE_BACKEND_RESOURCE` | the opened sandbox session | the `sandbox` capability | +| `WORKSPACE_RESOURCE` | the opened workspace, which the run also gets as `ctx.workspace` | `tool_output_limits`, and a delegate that shares `sandbox` | | `SUBAGENT_RUNTIME_RESOURCE` | the resolved delegation tree | the `subagents` capability | Delegation is the sharpest case for the rule. A delegate is a row; so are its pinned diff --git a/docs/architecture.pl.md b/docs/architecture.pl.md index d5a4e36c5..85186e98c 100644 --- a/docs/architecture.pl.md +++ b/docs/architecture.pl.md @@ -1,5 +1,5 @@ --- -source_sha: "8d9cf7ac71b1" +source_sha: "d6244bf5e175" --- # Architektura { #architecture } @@ -361,7 +361,7 @@ Dwa wpisy w tym słowniku są szwami do innych podsystemów, a nie zwykłymi dan | Zasób | Zostawiony przez runner | Czytany przez | |---|---|---| -| `WORKSPACE_BACKEND_RESOURCE` | otwarta sesja sandboksa | capability `sandbox` | +| `WORKSPACE_RESOURCE` | otwarty workspace, który run dostaje też jako `ctx.workspace` | `tool_output_limits` oraz delegat, który współdzieli `sandbox` | | `SUBAGENT_RUNTIME_RESOURCE` | rozwiązane drzewo delegacji | capability `subagents` | Delegacja jest najostrzejszym przypadkiem tej zasady. Delegat jest wierszem; tak diff --git a/docs/licenses.de.md b/docs/licenses.de.md index f5dc98514..c04af74ef 100644 --- a/docs/licenses.de.md +++ b/docs/licenses.de.md @@ -1,5 +1,5 @@ --- -source_sha: "7e8688fd9cd6" +source_sha: "f65f76a23f9a" --- # Lizenzen und Drittanbieter-Hinweise { #licences-and-third-party-notices } @@ -116,7 +116,7 @@ Notices-Datei ist die aktuelle Zahl. | MIT, ISC, BSD-2-Clause, BSD-3-Clause, 0BSD, MIT-0, MIT-CMU, Unlicense | rund 400 | Copyright-Hinweis und Lizenztext bei Kopien mitführen | Die eigene Lizenzdatei jedes Pakets wird im Image neben dem Code ausgeliefert: das `*.dist-info/` jedes Wheels im Backend-Image, die Lizenzdatei jedes Pakets unter `/app/licenses/node_modules//` im Frontend-Image. Die Notices indexieren sie | | Apache-2.0 | rund 90 | Der Lizenztext, ein Hinweis auf Änderungen, jede `NOTICE`-Datei, die das Paket mitführt | Wie oben; nichts wird verändert, es gibt also keine Änderungen, auf die hinzuweisen wäre | | PSF-2.0, CNRI-Python, Zlib, CC0-1.0 | ein paar | Namensnennung oder nichts | Wie oben | -| MPL-2.0 (`certifi`, `pathspec`, `tqdm`, Teil von `orjson`) | 4 | Copyleft auf Dateiebene: Die erfassten Dateien bleiben unter der MPL, und ihr Quellcode ist verfügbar | Unverändert genutzt; der Lizenztext wird ausgeliefert; die Notices verlinken den Quellcode | +| MPL-2.0 (`bidict`, `certifi`, `pathspec`, `tqdm`, Teil von `orjson`) | 5 | Copyleft auf Dateiebene: Die erfassten Dateien bleiben unter der MPL, und ihr Quellcode ist verfügbar | Unverändert genutzt; der Lizenztext wird ausgeliefert; die Notices verlinken den Quellcode | | LGPL-3.0-or-later (`psycopg2-binary`, `@img/sharp-libvips-linux-*`) | 3 | Lizenztext, Verfügbarkeit des Quellcodes und die Möglichkeit, die Bibliothek zu ersetzen | Beide sind separat installierte Binaries, dynamisch geladen, unverändert, durch Neuinstallation ersetzbar; die Quellen sind in den Notices verlinkt. Die libvips-Pakete veröffentlichen keine Lizenzdatei, deshalb legt das Image den LGPL-Text daneben | | LGPL-3.0-only (`ldap3`) | 1 | Lizenztext, Verfügbarkeit des Quellcodes und die Möglichkeit, die Bibliothek zu ersetzen | Der LDAP-Client der Verzeichnisanmeldung, ein reines Python-Wheel, unverändert importiert und durch Neuinstallation ersetzbar; seine Lizenzdateien liegen in seiner dist-info, und sein Quellcode ist in den Notices verlinkt | | Artistic-1.0-Perl oder GPL-2.0-or-later (`text-unidecode`) | 1 | Dual; unter der Artistic License genommen: Hinweis und Text | Die Lizenzdatei des Wheels wird ausgeliefert | diff --git a/docs/licenses.es.md b/docs/licenses.es.md index f3046fac1..9127ffa95 100644 --- a/docs/licenses.es.md +++ b/docs/licenses.es.md @@ -1,5 +1,5 @@ --- -source_sha: "7e8688fd9cd6" +source_sha: "f65f76a23f9a" --- # Licencias y avisos de terceros { #licences-and-third-party-notices } @@ -114,7 +114,7 @@ cifra actual es la del propio archivo de avisos. | MIT, ISC, BSD-2-Clause, BSD-3-Clause, 0BSD, MIT-0, MIT-CMU, Unlicense | unos 400 | Conservar el aviso de copyright y el texto de la licencia junto con las copias | El archivo de licencia de cada paquete viaja dentro de la imagen, al lado del código: el `*.dist-info/` de cada wheel en la imagen del backend, y el archivo de licencia de cada paquete bajo `/app/licenses/node_modules//` en la del frontend. Los avisos los indexan | | Apache-2.0 | unos 90 | El texto de la licencia, el aviso de los cambios y cualquier archivo `NOTICE` que lleve el paquete | Igual que arriba; no se modifica nada, así que no hay cambios de los que avisar | | PSF-2.0, CNRI-Python, Zlib, CC0-1.0 | unos pocos | Atribución o nada | Igual que arriba | -| MPL-2.0 (`certifi`, `pathspec`, `tqdm`, parte de `orjson`) | 4 | Copyleft por archivo: los archivos cubiertos siguen bajo MPL y su código fuente está disponible | Se usan sin modificar; el texto de la licencia viaja con ellos; los avisos enlazan el código fuente | +| MPL-2.0 (`bidict`, `certifi`, `pathspec`, `tqdm`, parte de `orjson`) | 5 | Copyleft por archivo: los archivos cubiertos siguen bajo MPL y su código fuente está disponible | Se usan sin modificar; el texto de la licencia viaja con ellos; los avisos enlazan el código fuente | | LGPL-3.0-or-later (`psycopg2-binary`, `@img/sharp-libvips-linux-*`) | 3 | Texto de la licencia, disponibilidad del código fuente y la posibilidad de sustituir la biblioteca | Ambos son binarios instalados por separado y cargados dinámicamente, sin modificar, sustituibles reinstalándolos; el código fuente va enlazado en los avisos. Los paquetes de libvips no publican archivo de licencia, así que la imagen coloca el texto de la LGPL junto a ellos | | LGPL-3.0-only (`ldap3`) | 1 | Texto de la licencia, disponibilidad del código fuente y la posibilidad de sustituir la biblioteca | El cliente LDAP del inicio de sesión con el directorio, un wheel de Python puro importado sin modificar y sustituible reinstalándolo; sus archivos de licencia van en su dist-info y su código fuente va enlazado en los avisos | | Artistic-1.0-Perl o GPL-2.0-or-later (`text-unidecode`) | 1 | Dual; se toma bajo la Artistic License: aviso y texto | El archivo de licencia del wheel viaja con él | diff --git a/docs/licenses.md b/docs/licenses.md index dadecbd63..a4f632ff0 100644 --- a/docs/licenses.md +++ b/docs/licenses.md @@ -100,7 +100,7 @@ the current figure. | MIT, ISC, BSD-2-Clause, BSD-3-Clause, 0BSD, MIT-0, MIT-CMU, Unlicense | about 400 | Keep the copyright notice and the licence text with copies | Each package's own licence file ships in the image, next to the code: every wheel's `*.dist-info/` in the backend image, every package's licence file under `/app/licenses/node_modules//` in the frontend image. The notices index them | | Apache-2.0 | about 90 | The licence text, notice of changes, any `NOTICE` file the package carries | Same as above; nothing is modified, so there are no changes to notice | | PSF-2.0, CNRI-Python, Zlib, CC0-1.0 | a few | Attribution or nothing | Same as above | -| MPL-2.0 (`certifi`, `pathspec`, `tqdm`, part of `orjson`) | 4 | File-level copyleft: the covered files stay under MPL and their source is available | Used unmodified; the licence text ships; the notices link the source | +| MPL-2.0 (`bidict`, `certifi`, `pathspec`, `tqdm`, part of `orjson`) | 5 | File-level copyleft: the covered files stay under MPL and their source is available | Used unmodified; the licence text ships; the notices link the source | | LGPL-3.0-or-later (`psycopg2-binary`, `@img/sharp-libvips-linux-*`) | 3 | Licence text, source availability, and the ability to replace the library | Both are separately installed binaries loaded dynamically, unmodified, replaceable by reinstalling; sources linked in the notices. The libvips packages publish no licence file, so the image places the LGPL text beside them | | LGPL-3.0-only (`ldap3`) | 1 | Licence text, source availability, and the ability to replace the library | The directory sign-in's LDAP client, a pure-Python wheel imported unmodified and replaceable by reinstalling; its licence files ship in its dist-info and its source is linked in the notices | | Artistic-1.0-Perl or GPL-2.0-or-later (`text-unidecode`) | 1 | Dual; taken under the Artistic License: notice and text | The wheel's licence file ships | diff --git a/docs/licenses.pl.md b/docs/licenses.pl.md index 303f0b4cd..efad2c8c5 100644 --- a/docs/licenses.pl.md +++ b/docs/licenses.pl.md @@ -1,5 +1,5 @@ --- -source_sha: "7e8688fd9cd6" +source_sha: "f65f76a23f9a" --- # Licencje i noty stron trzecich { #licences-and-third-party-notices } @@ -108,7 +108,7 @@ not. | MIT, ISC, BSD-2-Clause, BSD-3-Clause, 0BSD, MIT-0, MIT-CMU, Unlicense | około 400 | Zachować notę o prawach autorskich i tekst licencji przy kopiach | Plik licencji każdego pakietu jedzie w obrazie, obok kodu: każde `*.dist-info/` wheela w obrazie backendu, plik licencji każdego pakietu pod `/app/licenses/node_modules//` w obrazie frontendu. Noty je indeksują | | Apache-2.0 | około 90 | Tekst licencji, informacja o zmianach, każdy plik `NOTICE`, który niesie pakiet | Jak wyżej; nic nie jest modyfikowane, więc nie ma zmian, o których trzeba informować | | PSF-2.0, CNRI-Python, Zlib, CC0-1.0 | kilka | Atrybucja albo nic | Jak wyżej | -| MPL-2.0 (`certifi`, `pathspec`, `tqdm`, część `orjson`) | 4 | Copyleft na poziomie plików: objęte pliki zostają na MPL, a ich źródła są dostępne | Używane bez modyfikacji; tekst licencji jedzie w obrazie; noty linkują źródła | +| MPL-2.0 (`bidict`, `certifi`, `pathspec`, `tqdm`, część `orjson`) | 5 | Copyleft na poziomie plików: objęte pliki zostają na MPL, a ich źródła są dostępne | Używane bez modyfikacji; tekst licencji jedzie w obrazie; noty linkują źródła | | LGPL-3.0-or-later (`psycopg2-binary`, `@img/sharp-libvips-linux-*`) | 3 | Tekst licencji, dostępność źródeł i możliwość podmiany biblioteki | Oba to osobno instalowane binaria ładowane dynamicznie, bez modyfikacji, wymienialne przez reinstalację; źródła zalinkowane w notach. Pakiety libvips nie publikują pliku licencji, więc obraz kładzie tekst LGPL obok nich | | LGPL-3.0-only (`ldap3`) | 1 | Tekst licencji, dostępność źródeł i możliwość podmiany biblioteki | Klient LDAP logowania katalogowego, czysto pythonowy wheel importowany bez modyfikacji i wymienialny przez reinstalację; jego pliki licencji są w jego dist-info, a źródła zalinkowane w notach | | Artistic-1.0-Perl albo GPL-2.0-or-later (`text-unidecode`) | 1 | Podwójna; wzięta na Artistic License: nota i tekst | Plik licencji z wheela jedzie w obrazie | diff --git a/docs/sandbox.de.md b/docs/sandbox.de.md index 450f7dfb8..259d81578 100644 --- a/docs/sandbox.de.md +++ b/docs/sandbox.de.md @@ -1,5 +1,5 @@ --- -source_sha: "32ecd84a00b7" +source_sha: "28df8e05f4b0" --- # Der Sandbox { #the-sandbox } @@ -84,6 +84,20 @@ Der **Scope** ist ein Feld des Specs des Agents — `run`, `conversation`, `chan und ein Verzeichnis pro Chat; `agent` bedeutet, dass sich jeder Run dieses Agents einen teilt. +Der Schlüssel ist auch der **Name** der Session auf dem Host — die Session-ID bei +`sandboxd`, der Name einer Daytona-Sandbox —, also öffnet jeder Run eines Scopes +sie über diesen Namen: Der erste legt sie an, die anderen hängen sich an, +gleichzeitige eingeschlossen, und nichts muss erst die eigene ID eines Anbieters +speichern, bevor der zweite Run sie finden kann. + +**Ist eine Session einmal geöffnet, hängt sich ein späterer Run nur noch an sie an.** +Die Workspace-Zeile hält sie fest, wenn der Run endet, der sie geöffnet hat, und der +nächste Run fragt den Host nach dieser Session statt nach dem Namen. Eine Session, +deren Dateien gelöscht wurden — durch `SANDBOXD_WORKSPACE_TTL` entfernt, ein Host neu +aufgesetzt —, wird dem Agenten dann als verloren gemeldet, statt stillschweigend durch +eine leere ersetzt zu werden, in der er weiterarbeiten würde. Die Zeile vergisst sie am +Ende dieses Runs, sodass der nächste Turn eine frische Session beginnt. + Ebenfalls in den Schlüssel eingefaltet: welche **Backend-Art** und welcher **Host** den Workspace trägt. Ein `state`-Dokument und das Volume eines Containers sind nicht dieselbe Sache unter verschiedenen Namen, und zwei `sandboxd`-Installationen sind es @@ -370,13 +384,14 @@ Nichts außerhalb dieses Prozesses hat die Einträge je gesehen (#1061). Jeder Workspace-Aufruf geht ohnehin durch diese Anwendung — der Run ruft uns auf, wir rufen den Service auf — der Nachweis ist also unserer. -`RecordingBackend` umhüllt das Backend, das die Tools der Capability erreichen, -weshalb ein neuntes Tool hinzuzufügen das Aufzeichnen nicht vergessen kann. Der -Wrapper zeichnet acht benannte Operationen auf (`write`, `edit`, `read`, -`read_bytes`, `ls_info`, `glob_info`, `grep_raw`, `execute`) und reicht alles andere -unangetastet weiter. `exists` und `is_alive` sind Fragen und keine Operationen, und -ein Log voll davon würde die Schreibvorgänge begraben, wegen derer jemand gekommen -ist. +`RecordingWorkspace` umhüllt den Workspace, in dem der Run arbeitet, weshalb ein +neuntes Tool hinzuzufügen das Aufzeichnen nicht vergessen kann. Aufgezeichnet wird, +was die Sandbox erreicht hat — `read`, `write`, `ls_info`, `mkdir`, `remove` und +`execute` —, sodass ein Tool, das aus mehreren Operationen besteht, als jede davon +erscheint: ein `edit_file` ist ein `read` und ein `write`, ein `glob` oder `grep` +das `execute` des `find` oder `grep`, das es ausgeführt hat. `exists` und `stat` +sind Fragen und keine Operationen, und ein Log voll davon würde die +Schreibvorgänge begraben, wegen derer jemand gekommen ist. Er trägt zwei Tatsachen, die der Service nie tragen konnte, und es sind die beiden, nach denen ein Audit tatsächlich fragt: **welcher Agent, und welcher Run**. Beide diff --git a/docs/sandbox.es.md b/docs/sandbox.es.md index 2ec5b3901..46ec8eb5a 100644 --- a/docs/sandbox.es.md +++ b/docs/sandbox.es.md @@ -1,5 +1,5 @@ --- -source_sha: "32ecd84a00b7" +source_sha: "28df8e05f4b0" --- # La sandbox { #the-sandbox } @@ -83,6 +83,20 @@ El **scope** es un campo del spec del agent: `run`, `conversation`, `channel`, contenedor y un directorio por chat; `agent` significa que todos los runs de ese agent comparten uno. +La clave es también el **nombre** de la sesión en el host —el id de una sesión de +`sandboxd`, el nombre de un sandbox de Daytona—, así que cada run de un scope la +abre por ese nombre: el primero la crea y los demás se conectan a ella, también los +concurrentes, y nada tiene que guardar el id propio de un proveedor antes de que el +segundo run pueda encontrarla. + +**Una vez abierta una sesión, un run posterior solo se conecta a ella.** La fila del +workspace la registra cuando termina el run que la abrió, y el siguiente run pide al +host esa sesión y no el nombre. Una sesión cuyos archivos se purgaron —barridos por +`SANDBOXD_WORKSPACE_TTL`, un host reconstruido— se notifica entonces al agente como +perdida, en lugar de sustituirse en silencio por una vacía en la que seguiría +trabajando. La fila la olvida al final de ese run, así que el turno siguiente empieza +una sesión nueva. + También se agrupan en la clave: qué **tipo de backend** y qué **host** alojan el workspace. Un documento `state` y el volumen de un contenedor no son la misma cosa con distinto nombre, y tampoco lo son dos instalaciones de `sandboxd`: @@ -359,12 +373,13 @@ proceso vio nunca las entradas (#1061). Cada llamada al workspace ya pasa por esta aplicación —el run nos llama a nosotros, nosotros llamamos al servicio—, así que el registro es nuestro. -`RecordingBackend` envuelve el backend al que llegan las tools de la capability, -que es por lo que añadir una novena tool no puede olvidarse de registrar. El -envoltorio registra ocho operaciones con nombre (`write`, `edit`, `read`, -`read_bytes`, `ls_info`, `glob_info`, `grep_raw`, `execute`) y delega todo lo -demás sin tocarlo. `exists` e `is_alive` son preguntas y no operaciones, y un -registro lleno de ellas enterraría las escrituras que alguien vino a leer. +`RecordingWorkspace` envuelve el workspace en el que trabaja el run, que es por lo +que añadir una novena tool no puede olvidarse de registrar. Registra lo que llegó +al sandbox —`read`, `write`, `ls_info`, `mkdir`, `remove` y `execute`—, así que una +tool que son varias operaciones aparece como cada una de ellas: un `edit_file` es +un `read` y un `write`; un `glob` o un `grep`, el `execute` del `find` o del `grep` +que ejecutó. `exists` y `stat` son preguntas y no operaciones, y un registro lleno +de ellas enterraría las escrituras que alguien vino a leer. Lleva dos hechos que el servicio nunca podría, y son los dos que una auditoría pide de verdad: **qué agent y qué run**. Ambos son `SET NULL` al borrar, porque el diff --git a/docs/sandbox.md b/docs/sandbox.md index f26e64fd8..2a1195f6e 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -75,6 +75,18 @@ The **scope** is a field of the agent's spec — `run`, `conversation`, `channel `user` or `agent`. So `conversation`, the usual choice, means one container and one directory per chat; `agent` means every run of that agent shares one. +The key is also the session's **name** on the host — a `sandboxd` session's id, a +Daytona sandbox's name — so every run of a scope opens it by that name: the first +creates it and the others attach, concurrent ones included, and nothing has to +record a provider's own id before the second run can find it. + +**Once a session has been opened, a later run only attaches to it.** The workspace +row records it when the run that opened it ends, and the next run asks the host for +that session rather than for the name. A session whose files were purged — swept by +`SANDBOXD_WORKSPACE_TTL`, a host rebuilt — is then reported to the agent as gone, +instead of being quietly replaced by an empty one it would carry on in. The row +forgets it at the end of that run, so the turn after starts a fresh session. + Folded into the key as well: which **backend kind** and which **host** the workspace lives on. A `state` document and a container's volume are not the same thing wearing different names, and neither are two `sandboxd` installations — @@ -337,12 +349,13 @@ the host. Nothing outside that process ever saw the entries (#1061). Every workspace call already passes through this application — the run calls us, we call the service — so the record is ours to make. -`RecordingBackend` wraps the backend the capability's tools reach, which is why -adding a ninth tool cannot forget to record. The wrapper records eight named -operations (`write`, `edit`, `read`, `read_bytes`, `ls_info`, `glob_info`, -`grep_raw`, `execute`) and delegates everything else untouched. `exists` and -`is_alive` are questions rather than operations, and a log full of them would bury -the writes somebody came to read. +`RecordingWorkspace` wraps the workspace the run works in, which is why adding a +ninth tool cannot forget to record. It records what reached the sandbox — `read`, +`write`, `ls_info`, `mkdir`, `remove` and `execute` — so a tool that is several +operations shows as each of them: an `edit_file` is a `read` and a `write`, a +`glob` or a `grep` the `execute` of the `find` or `grep` it ran. `exists` and +`stat` are questions rather than operations, and a log full of them would bury the +writes somebody came to read. It carries two facts the service never could, and they are the two an audit actually asks for: **which agent, and which run**. Both are `SET NULL` on delete, diff --git a/docs/sandbox.pl.md b/docs/sandbox.pl.md index 44af7c4be..61253c7ba 100644 --- a/docs/sandbox.pl.md +++ b/docs/sandbox.pl.md @@ -1,5 +1,5 @@ --- -source_sha: "32ecd84a00b7" +source_sha: "28df8e05f4b0" --- # Sandbox { #the-sandbox } @@ -81,6 +81,18 @@ xc-4f2a91c8-7b3e5d10-9c1f… backend · scope · organization · host · su albo `agent`. Zatem `conversation`, czyli zwykły wybór, oznacza jeden kontener i jeden katalog na czat; `agent` oznacza, że każdy run tego agenta dzieli jeden. +Klucz jest też **nazwą** sesji na hoście — id sesji `sandboxd`, nazwą sandboksa +Daytona — więc każdy run danego zasięgu otwiera ją po tej nazwie: pierwszy ją +tworzy, a pozostałe się podłączają, także te równoległe, i nic nie musi zapisywać +id nadanego przez dostawcę, zanim drugi run będzie mógł ją znaleźć. + +**Gdy sesja raz zostanie otwarta, kolejny run już tylko się do niej podłącza.** +Wiersz workspace'u zapisuje ją, gdy kończy się run, który ją otworzył, a następny run +prosi host o tę sesję, a nie o nazwę. Sesja, której pliki usunięto — wymiecione przez +`SANDBOXD_WORKSPACE_TTL`, po przebudowie hosta — zostaje wtedy zgłoszona agentowi +jako utracona, zamiast po cichu zastąpić ją pustą, w której pracowałby dalej. Wiersz +zapomina ją na końcu tego runu, więc kolejna tura zaczyna świeżą sesję. + W klucz wpisane jest też, **jakiego rodzaju backend** i **jaki host** trzyma workspace. Dokument `state` i wolumen kontenera to nie ta sama rzecz pod różnymi nazwami, i tak samo nie są nią dwie instalacje `sandboxd` — rejestracja drugiego @@ -353,11 +365,12 @@ log na hoście. Nic poza tamtym procesem nigdy tych wpisów nie widziało (#1061 Każde wywołanie workspace'u i tak przechodzi przez tę aplikację — run woła nas, my wołamy usługę — więc to nasz zapis do zrobienia. -`RecordingBackend` opakowuje backend, do którego sięgają narzędzia tej capability, -i dlatego dodanie dziewiątego narzędzia nie może zapomnieć o zapisaniu. Wrapper -zapisuje osiem nazwanych operacji (`write`, `edit`, `read`, `read_bytes`, -`ls_info`, `glob_info`, `grep_raw`, `execute`), a wszystko inne deleguje -nietknięte. `exists` i `is_alive` są pytaniami, a nie operacjami, a log pełen ich +`RecordingWorkspace` opakowuje workspace, w którym pracuje run, i dlatego dodanie +dziewiątego narzędzia nie może zapomnieć o zapisaniu. Zapisuje to, co dotarło do +sandboksa — `read`, `write`, `ls_info`, `mkdir`, `remove` i `execute` — więc +narzędzie złożone z kilku operacji widać jako każdą z nich: `edit_file` to `read` +i `write`, a `glob` czy `grep` to `execute` polecenia `find` albo `grep`, które +uruchomiło. `exists` i `stat` są pytaniami, a nie operacjami, a log pełen ich przykryłby zapisy, po które ktoś przyszedł. Niesie dwa fakty, których usługa nigdy nie mogła, i są to dokładnie te dwa, diff --git a/licenses/policy.toml b/licenses/policy.toml index de1293d08..4a3e6c290 100644 --- a/licenses/policy.toml +++ b/licenses/policy.toml @@ -48,6 +48,10 @@ evidence = "The platform build of Next.js's compiler names no author and ships n holder = "Vercel, Inc." evidence = "As for @next/swc-linux-x64-gnu: the arm64 build from the same repository" +[notices.python."obstore"] +holder = "Development Seed" +evidence = "obstore-0.11.1 ships no licence file and its metadata names no author or licence; it is the package from https://github.com/developmentseed/obstore, whose LICENSE is MIT, Copyright (c) 2024 Development Seed. Reached through the Daytona SDK (daytona -> obstore)" + [notices.python."pydantic-monty"] holder = "Pydantic Services Inc." evidence = "The wheel names no author; its Project-URL is https://github.com/pydantic/monty, whose LICENSE is MIT, Copyright (c) Pydantic Services Inc. 2026 to present" @@ -91,6 +95,12 @@ status = "accepted" obligations = "Dual-licensed; taken under the Artistic License: keep the copyright notice and the licence text with the distribution, do not misrepresent the package." fulfilled_by = "Unmodified; the wheel's LICENSE.txt ships in the image; named with its source in THIRD_PARTY_NOTICES.md." +[review.python."bidict"] +license = "MPL-2.0" +status = "accepted" +obligations = "File-level copyleft on the library's own files; source availability for them. Reached through the Daytona SDK's Socket.IO client (daytona -> python-socketio -> bidict)." +fulfilled_by = "Unmodified; the wheel's LICENSE ships in the image; source at https://github.com/jab/bidict." + [review.python."certifi"] license = "MPL-2.0" status = "accepted"