Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 33 additions & 14 deletions THIRD_PARTY_NOTICES.md

Large diffs are not rendered by default.

37 changes: 37 additions & 0 deletions backend/alembic/versions/0105_workspace_directories.py
Original file line number Diff line number Diff line change
@@ -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"
Comment thread
DEENUU1 marked this conversation as resolved.
down_revision: str | None = "0104_model_profile_api"
Comment thread
DEENUU1 marked this conversation as resolved.
branch_labels: str | Sequence[str] | None = None
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
DEENUU1 marked this conversation as resolved.
depends_on: str | Sequence[str] | None = None
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
DEENUU1 marked this conversation as resolved.


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")
3 changes: 1 addition & 2 deletions backend/app/agents/capabilities/artifacts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down Expand Up @@ -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()
7 changes: 1 addition & 6 deletions backend/app/agents/capabilities/artifacts/_capability.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
17 changes: 12 additions & 5 deletions backend/app/agents/capabilities/artifacts/_toolset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -250,17 +250,24 @@ 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 "
"`content` instead.",
)
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,
Expand Down
8 changes: 5 additions & 3 deletions backend/app/agents/capabilities/compaction/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 10 additions & 5 deletions backend/app/agents/capabilities/compaction/_capability.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions backend/app/agents/capabilities/image_generation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 0 additions & 2 deletions backend/app/agents/capabilities/image_generation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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
24 changes: 16 additions & 8 deletions backend/app/agents/capabilities/image_generation/_toolset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
33 changes: 17 additions & 16 deletions backend/app/agents/capabilities/sandbox/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -36,7 +36,7 @@
)

__all__ = [
"WORKSPACE_BACKEND_RESOURCE",
"WORKSPACE_RESOURCE",
"BackendKind",
"SandboxConfig",
"SessionScope",
Expand All @@ -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.
"""


Expand Down Expand Up @@ -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)
Loading
Loading