From 8e01153be8c38e9a687cb94979caeab144be5ca2 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Thu, 29 Oct 2026 09:03:02 -0700 Subject: [PATCH 1/2] feat(task): work out what a stored task requires by the rule bundle register uses task_requires(steps, declared) gives the plugin packages behind a task's own step types and the types of the envs and artifacts its steps read from the store, with those they hold. A package the previous version required keeps its range; one used anew is at least the installed version; one no longer used is dropped unless a reference can't be read here, when every declared one is kept. A service that saves tasks without a bundle, such as the hub, calls it on every save. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 7 ++- src/agent_env/bundle/requires.py | 69 +++++++++++++++++++++++- tst/unit/bundle/requires_test.py | 92 +++++++++++++++++++++++++++----- 3 files changed, 152 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4daa66a9..758c3dfe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -196,8 +196,11 @@ Out-of-tree code plugs in either through `config.toml`, as a `module:Class` poin met, or whose steps read an env or artifact of a type nothing here registers, offering in a terminal to install what it requires (`cli/task/_checks.py`), then set up in local stores the infra envs its gateway deploys run on (`preflight.stored_infra`). A step type nothing registers names the task's unmet requirements in the load error - (`UnknownStepTypeError`). A task stored without `requires` loads and runs as before; a hub edit drops it until the - hub passes it through. + (`UnknownStepTypeError`). A task stored without `requires` loads and runs as before. A service that saves tasks + computes it by the same rule with `task_requires(steps, declared)` (`bundle/requires.py`), reading the envs and + artifacts the steps reference, and those they hold, from the store: a package the previous version required keeps its + range, one used anew is at least the installed version, and one no longer used is dropped unless a reference can't be + read. - Environment providers have no table: an installed package registers one as an `agent_env.env_providers` entry point named its `type`, which records carry as `env_provider_type`. - `[plugins.]` is reserved for an installed plugin's own settings, read with diff --git a/src/agent_env/bundle/requires.py b/src/agent_env/bundle/requires.py index 8d5d403a..9082461a 100644 --- a/src/agent_env/bundle/requires.py +++ b/src/agent_env/bundle/requires.py @@ -8,7 +8,7 @@ import json import os import uuid -from collections.abc import Iterable, Mapping, Sequence +from collections.abc import Iterable, Iterator, Mapping, Sequence from dataclasses import dataclass from enum import Enum from importlib.metadata import distribution, packages_distributions @@ -19,12 +19,18 @@ from packaging.utils import canonicalize_name from packaging.version import InvalidVersion, Version +from agent_env.artifact.artifact import Artifact from agent_env.artifact.registry import ARTIFACT_REGISTRY, canonical_type, get_artifact_registry +from agent_env.entity.refs import EntityKind, RefRole, ref_sites from agent_env.env import registry as env_registry +from agent_env.env.env import Env from agent_env.env.registry import get_env_registry from agent_env.plugins import ARTIFACTS, ENVS, TASK_STEPS, _requirements, inventory, load_failures +from agent_env.store.base import NotFoundError +from agent_env.store.document_store import UnknownDocumentTypeError from agent_env.task_step import registry as task_step_registry from agent_env.task_step.registry import get_task_step_registry +from agent_env.task_step.task_step import TaskStep from . import _toml from ._fs import relative @@ -36,6 +42,8 @@ # What core ships, per plugin group: anything else a bundle uses is provided by a plugin, or by configuration. _BUILTINS = {TASK_STEPS: task_step_registry._builtin_registry, ENVS: env_registry._builtin_registry, ARTIFACTS: lambda: ARTIFACT_REGISTRY} +# The entities a step reads whose type a plugin may register. +_GETTERS = {EntityKind.ENV: Env.get, EntityKind.ARTIFACT: Artifact.get} class UnmetRequirements(BundleError): @@ -180,6 +188,65 @@ def detected(found: Iterable[Use], declared: Iterable[str] = ()) -> list[str]: return sorted(requires) +def task_requires(steps: Iterable[TaskStep], declared: Iterable[str] = ()) -> list[str]: + """What a stored task made of ``steps`` requires, by the rule registering a bundle's task records: the plugin + packages behind its own step types and the types of the envs and artifacts its steps read, as the store holds them, + with those they hold. A package ``declared`` (what the task required before) names keeps its range there; one used + anew is at least the version installed here; one no longer used is dropped, unless a reference can't be read here + (an id the run fills in, one the store doesn't hold, a type nothing here registers), when every one is kept.""" + declared = list(declared) + providers = Providers() + found, read, unread = [], set(), False + for step in steps: + if (provider := providers.of(TASK_STEPS, step.type, get_task_step_registry().get(step.type))) is not None: + found.append(Use(f"step {step.id!r}", "step type", step.type, provider)) + for site in ref_sites(type(step).entity_refs or (), step.to_dict()): + if site.ref.role is RefRole.OUTPUT or site.ref.kind not in _GETTERS: + continue + key = (site.ref.kind, site.value, site.version if isinstance(site.version, int) else None) + if key in read: + continue + read.add(key) + try: + entity = _GETTERS[site.ref.kind](key[1], key[2]) if isinstance(key[1], str) else None + except (NotFoundError, UnknownDocumentTypeError, ValueError): + entity = None + if entity is None: + unread = True + continue + found += [use for held in _held(entity) if (use := _entity_use(f"step {step.id!r}: {site.path}", held, + providers)) is not None] + requires = detected(found, declared) + if unread: + requires = sorted({*requires, *(line for line in declared if _requirements.parsed(line) is not None)}) + return requires + + + +def _held(entity: Env | Artifact) -> Iterator[Env | Artifact]: + """``entity``, then each env and artifact loading it loaded with it, and theirs in turn.""" + seen, queue = set(), [entity] + while queue: + held = queue.pop() + if id(held) in seen: + continue + seen.add(id(held)) + yield held + for value in vars(held).values(): + items = value.values() if isinstance(value, dict) else value if isinstance(value, (list, tuple)) else (value,) + queue.extend(item for item in items if isinstance(item, (Env, Artifact))) + + +def _entity_use(where: str, entity: Env | Artifact, providers: Providers) -> Use | None: + """The use of ``entity``'s type, when core doesn't ship it.""" + if isinstance(entity, Env): + provider = providers.of(ENVS, entity.type, get_env_registry().get(entity.type)) + return Use(where, "env type", entity.type, provider) if provider is not None else None + name = canonical_type(entity.type) + provider = providers.of(ARTIFACTS, name, get_artifact_registry().get(name)) + return Use(where, "artifact type", name, provider) if provider is not None else None + + def declare(resolved: ResolvedBundle, declaring: Declaring) -> list[str]: """What to say of the plugin packages the bundle uses that its ``bundle.toml`` doesn't require, and of each type only this machine's configuration registers, as ``declaring`` says. Writing, a bundle with no ``bundle.toml`` is diff --git a/tst/unit/bundle/requires_test.py b/tst/unit/bundle/requires_test.py index 949670ae..4ad8fc50 100644 --- a/tst/unit/bundle/requires_test.py +++ b/tst/unit/bundle/requires_test.py @@ -16,23 +16,28 @@ from packaging.requirements import Requirement from agent_env.artifact import Artifact +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact +from agent_env.artifact.store import get_artifact_store from agent_env.bundle import BundleError, dry_run_bundle, parse_bundle, prepare_bundle, run_bundle from agent_env.bundle import register as register_module from agent_env.bundle import requires as requires_module from agent_env.bundle.importer import import_task from agent_env.bundle.register import register_bundle from agent_env.bundle.requires import ( - Declaring, Provider, Providers, UnmetRequirements, _create, declare, declaring_for, + Declaring, Provider, Providers, UnmetRequirements, _create, declare, declaring_for, task_requires, ) from agent_env.bundle.resolve import resolve_bundle from agent_env.bundle.run import _write_bundle from agent_env.cli import _install_prompt, cli from agent_env.cli._installers import InstallerError +from agent_env.config import get_config from agent_env.config.runtime import Config +from agent_env.env import MCPServerEnv from agent_env.plugins import ARTIFACTS, TASK_STEPS, Contribution, Distribution from agent_env.plugins._requirements import install_command, unmet, unmet_problems from agent_env.store.routing import namespace_routing from agent_env.task import Task +from agent_env.task.store import TASKS_COLLECTION from agent_env.task_step.task_step import TaskStep from tst.unit._terminal import at_a_terminal from tst.unit.bundle._support import fake_plugins, layout @@ -612,19 +617,33 @@ def from_toml(cls, data, ctx): } -class TestRegisteredTasks: - @pytest.fixture(autouse=True) - def two_plugins(self, stores, monkeypatch): - """agentenv-acme's step type, and agentenv-acme-settings's artifact type, which a task references.""" - monkeypatch.setattr(register_module, "_git", lambda root, *args: None) - artifacts = {**Config().artifact_registry(), _AcmeSetting.model_fields["type"].default: _AcmeSetting} - monkeypatch.setattr(Config, "artifact_registry", lambda self: artifacts) - fake_plugins(monkeypatch, [ - Distribution(ACME, "1.2.0", (Contribution(TASK_STEPS, _Acme.type, _value(_Acme), "active"),)), - Distribution(SETTINGS, "2.0", (Contribution(ARTIFACTS, _AcmeSetting.model_fields["type"].default, - _value(_AcmeSetting), "active"),)), - ], versions={ACME: "1.2.0", SETTINGS: "2.0"}) +class _AcmeImage(DockerImageArtifact): + """An image artifact type the plugin agentenv-acme-images ships, which an env holds.""" + + type: Literal["acme_image_requires_test"] = "acme_image_requires_test" + + +IMAGES = "agentenv-acme-images" + +@pytest.fixture +def plugins(stores, monkeypatch): + """agentenv-acme's step type, agentenv-acme-settings's artifact type, which a task references, and + agentenv-acme-images's, which an env holds.""" + monkeypatch.setattr(register_module, "_git", lambda root, *args: None) + artifacts = {**Config().artifact_registry(), + **{cls.model_fields["type"].default: cls for cls in (_AcmeSetting, _AcmeImage)}} + monkeypatch.setattr(Config, "artifact_registry", lambda self: artifacts) + fake_plugins(monkeypatch, [ + Distribution(ACME, "1.2.0", (Contribution(TASK_STEPS, _Acme.type, _value(_Acme), "active"),)), + *(Distribution(name, version, (Contribution(ARTIFACTS, cls.model_fields["type"].default, _value(cls), + "active"),)) + for name, version, cls in ((SETTINGS, "2.0", _AcmeSetting), (IMAGES, "3.0", _AcmeImage))), + ], versions={ACME: "1.2.0", SETTINGS: "2.0", IMAGES: "3.0"}) + + +@pytest.mark.usefixtures("plugins") +class TestRegisteredTasks: def test_each_task_requires_what_it_and_the_bundle_entities_it_references_use(self, stores): root = layout(stores / "b", REGISTERED) @@ -659,5 +678,52 @@ def test_an_import_of_a_registered_task_keeps_the_range_it_records(self, stores) assert parse_bundle(imported.root).requires == ("agentenv-acme>=1.0,<2",) +def _steps(steps): + """The steps of task ``t``, stored as ``steps`` document by document, as ``Task.get`` loads them.""" + get_config().get_document_store().insert(TASKS_COLLECTION, {"id": "t", "type": "task", "version": 1, + "steps": steps}) + return Task.get("t").steps + + +@pytest.mark.usefixtures("plugins") +class TestStoredTasks: + def test_a_registered_task_requires_what_registering_it_recorded(self, stores): + register_bundle(layout(stores / "b", REGISTERED)) + + for name in ("plugin", "loads", "core"): + task = Task.get(f"b-{name}") + assert task_requires(task.steps) == (task.requires or []) + + def test_a_package_it_required_keeps_its_range_one_used_anew_is_at_least_the_installed_version(self, stores): + register_bundle(layout(stores / "b", REGISTERED)) + declared = ["agentenv-acme>=1.0,<2", "agentenv-acme-settings"] + + assert task_requires(Task.get("b-plugin").steps, declared) == ["agentenv-acme>=1.0,<2"] + assert task_requires(Task.get("b-loads").steps, ["agentenv-acme>=1.0,<2"]) == ["agentenv-acme-settings>=2.0"] + assert task_requires(Task.get("b-core").steps, declared) == [] + + def test_a_type_only_configuration_registers_is_never_required(self, stores): + assert task_requires(_steps([{"id": "s", "type": _Configured.type}])) == [] + + def test_a_type_an_env_it_reads_holds_is_required(self, stores): + with namespace_routing(): + image = get_artifact_store().put_document(_AcmeImage( + id="crm-image", description="crm", image_name="crm:latest", tar_gz_s3_url=None, platform=None)) + MCPServerEnv.put(id="crm", docker_image_artifact=image, environment_name="crm") + + assert task_requires(_steps([{"id": "env", "type": "deploy_env", "env_id": "crm"}])) == [ + "agentenv-acme-images>=3.0"] + + @pytest.mark.parametrize("artifact_id", ["absent", "{{seed.artifact}}", "unknown"]) + def test_a_reference_it_cant_read_keeps_every_package_it_required(self, stores, artifact_id): + get_config().get_document_store().insert("artifacts", {"id": "unknown", "type": "acme_unknown_requires_test", + "version": 1}) + steps = _steps([SANDBOX, {"id": "s", "type": _Acme.type}, + {"id": "load", "type": "load_artifact", "sandbox_name": "box", "artifact_id": artifact_id, + "depends_on": [{"task_step_id": "box"}]}]) + + assert task_requires(steps, ["agentenv-acme-settings", "agentenv-acme>=1.0,<2", "not a requirement!"]) == [ + "agentenv-acme-settings", "agentenv-acme>=1.0,<2"] + def test_unmet_requirements_are_a_bundle_error(): assert issubclass(UnmetRequirements, BundleError) From 0d90def8b9df1aeda843168e2d00098708e13d72 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Thu, 29 Oct 2026 09:10:27 -0700 Subject: [PATCH 2/2] fix(task): read the agents a stored task's steps deploy for the images they hold An agent can hold an image artifact of a plugin's type, so task_requires now reads agent references too and walks what the agent holds, as it does for envs. An agent it can't read keeps every declared package, as any unreadable reference does. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 8 ++++---- src/agent_env/bundle/requires.py | 15 +++++++++------ tst/unit/bundle/requires_test.py | 12 ++++++++---- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 758c3dfe..fdb41c0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -197,10 +197,10 @@ Out-of-tree code plugs in either through `config.toml`, as a `module:Class` poin it requires (`cli/task/_checks.py`), then set up in local stores the infra envs its gateway deploys run on (`preflight.stored_infra`). A step type nothing registers names the task's unmet requirements in the load error (`UnknownStepTypeError`). A task stored without `requires` loads and runs as before. A service that saves tasks - computes it by the same rule with `task_requires(steps, declared)` (`bundle/requires.py`), reading the envs and - artifacts the steps reference, and those they hold, from the store: a package the previous version required keeps its - range, one used anew is at least the installed version, and one no longer used is dropped unless a reference can't be - read. + computes it by the same rule with `task_requires(steps, declared)` (`bundle/requires.py`), reading the envs, agents + and artifacts the steps reference, and those they hold, from the store: a package the previous version required + keeps its range, one used anew is at least the installed version, and one no longer used is dropped unless a + reference can't be read. - Environment providers have no table: an installed package registers one as an `agent_env.env_providers` entry point named its `type`, which records carry as `env_provider_type`. - `[plugins.]` is reserved for an installed plugin's own settings, read with diff --git a/src/agent_env/bundle/requires.py b/src/agent_env/bundle/requires.py index 9082461a..f3b5e0fd 100644 --- a/src/agent_env/bundle/requires.py +++ b/src/agent_env/bundle/requires.py @@ -19,6 +19,7 @@ from packaging.utils import canonicalize_name from packaging.version import InvalidVersion, Version +from agent_env.a2a_agent.a2a_agent import A2AAgent from agent_env.artifact.artifact import Artifact from agent_env.artifact.registry import ARTIFACT_REGISTRY, canonical_type, get_artifact_registry from agent_env.entity.refs import EntityKind, RefRole, ref_sites @@ -42,8 +43,8 @@ # What core ships, per plugin group: anything else a bundle uses is provided by a plugin, or by configuration. _BUILTINS = {TASK_STEPS: task_step_registry._builtin_registry, ENVS: env_registry._builtin_registry, ARTIFACTS: lambda: ARTIFACT_REGISTRY} -# The entities a step reads whose type a plugin may register. -_GETTERS = {EntityKind.ENV: Env.get, EntityKind.ARTIFACT: Artifact.get} +# The entities a step reads that hold, or are, a type a plugin may register. +_GETTERS = {EntityKind.ENV: Env.get, EntityKind.AGENT: A2AAgent.get, EntityKind.ARTIFACT: Artifact.get} class UnmetRequirements(BundleError): @@ -191,7 +192,7 @@ def detected(found: Iterable[Use], declared: Iterable[str] = ()) -> list[str]: def task_requires(steps: Iterable[TaskStep], declared: Iterable[str] = ()) -> list[str]: """What a stored task made of ``steps`` requires, by the rule registering a bundle's task records: the plugin packages behind its own step types and the types of the envs and artifacts its steps read, as the store holds them, - with those they hold. A package ``declared`` (what the task required before) names keeps its range there; one used + with those they and the agents its steps deploy hold. A package ``declared`` (what the task required before) names keeps its range there; one used anew is at least the version installed here; one no longer used is dropped, unless a reference can't be read here (an id the run fills in, one the store doesn't hold, a type nothing here registers), when every one is kept.""" declared = list(declared) @@ -223,7 +224,7 @@ def task_requires(steps: Iterable[TaskStep], declared: Iterable[str] = ()) -> li -def _held(entity: Env | Artifact) -> Iterator[Env | Artifact]: +def _held(entity: Env | A2AAgent | Artifact) -> Iterator[Env | A2AAgent | Artifact]: """``entity``, then each env and artifact loading it loaded with it, and theirs in turn.""" seen, queue = set(), [entity] while queue: @@ -237,11 +238,13 @@ def _held(entity: Env | Artifact) -> Iterator[Env | Artifact]: queue.extend(item for item in items if isinstance(item, (Env, Artifact))) -def _entity_use(where: str, entity: Env | Artifact, providers: Providers) -> Use | None: - """The use of ``entity``'s type, when core doesn't ship it.""" +def _entity_use(where: str, entity: Env | A2AAgent | Artifact, providers: Providers) -> Use | None: + """The use of ``entity``'s type, when core doesn't ship it; an agent's has no plugin group.""" if isinstance(entity, Env): provider = providers.of(ENVS, entity.type, get_env_registry().get(entity.type)) return Use(where, "env type", entity.type, provider) if provider is not None else None + if not isinstance(entity, Artifact): + return None name = canonical_type(entity.type) provider = providers.of(ARTIFACTS, name, get_artifact_registry().get(name)) return Use(where, "artifact type", name, provider) if provider is not None else None diff --git a/tst/unit/bundle/requires_test.py b/tst/unit/bundle/requires_test.py index 4ad8fc50..78096398 100644 --- a/tst/unit/bundle/requires_test.py +++ b/tst/unit/bundle/requires_test.py @@ -15,6 +15,7 @@ from click.testing import CliRunner from packaging.requirements import Requirement +from agent_env.a2a_agent import A2AAgent from agent_env.artifact import Artifact from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.artifact.store import get_artifact_store @@ -705,14 +706,17 @@ def test_a_package_it_required_keeps_its_range_one_used_anew_is_at_least_the_ins def test_a_type_only_configuration_registers_is_never_required(self, stores): assert task_requires(_steps([{"id": "s", "type": _Configured.type}])) == [] - def test_a_type_an_env_it_reads_holds_is_required(self, stores): + @pytest.mark.parametrize("step", [{"id": "env", "type": "deploy_env", "env_id": "crm"}, + {"id": "agent", "type": "deploy_agent", "a2a_agent_id": "coder"}], + ids=["env", "agent"]) + def test_a_type_an_env_or_agent_it_reads_holds_is_required(self, stores, step): with namespace_routing(): image = get_artifact_store().put_document(_AcmeImage( - id="crm-image", description="crm", image_name="crm:latest", tar_gz_s3_url=None, platform=None)) + id="acme-image", description="acme", image_name="acme:latest", tar_gz_s3_url=None, platform=None)) MCPServerEnv.put(id="crm", docker_image_artifact=image, environment_name="crm") + A2AAgent.put(id="coder", docker_image_artifact=image) - assert task_requires(_steps([{"id": "env", "type": "deploy_env", "env_id": "crm"}])) == [ - "agentenv-acme-images>=3.0"] + assert task_requires(_steps([step])) == ["agentenv-acme-images>=3.0"] @pytest.mark.parametrize("artifact_id", ["absent", "{{seed.artifact}}", "unknown"]) def test_a_reference_it_cant_read_keeps_every_package_it_required(self, stores, artifact_id):