From efc1b6afb2d92e1e0a2a807888abe1db9e3b9935 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 08:00:03 +0000 Subject: [PATCH 1/2] fix(a2a): resolve a role's environment to a provisioned id (FA-14) _provision() previously left environment_id null for every A2A-provisioned session: ensure_agent() never returns one, and no tenant sets provider.environmentId. Add EnvironmentResolver (a2a/environments.py), mirroring providers/provision.py's basename->name->id lookup exactly, and wire it into A2AAdapter.__init__ (cached for the adapter's lifetime, never re-read per call). Precedence: tenant.provider.environment_id overrides everything; else a role naming an environment resolves through EnvironmentResolver, which raises rather than silently returning null when the name can't be resolved; a role naming no environment legitimately resolves to None. Adds agent-templates/environments/ (cloud-orchestrator.json) and binds it to the served agent-orchestrator role so the fix has a real environment to resolve against in this repo, not just in tests. Jira: FA-14 (epic FA-2) Co-Authored-By: Claude Opus 4.8 --- agent-templates/a2a/adapter.py | 33 ++- agent-templates/a2a/environments.py | 117 +++++++++ .../a2a/tests/test_environments.py | 248 ++++++++++++++++++ .../environments/cloud-orchestrator.json | 17 ++ .../roles/agent-orchestrator/role.json | 1 + 5 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 agent-templates/a2a/environments.py create mode 100644 agent-templates/a2a/tests/test_environments.py create mode 100644 agent-templates/environments/cloud-orchestrator.json diff --git a/agent-templates/a2a/adapter.py b/agent-templates/a2a/adapter.py index f6fa746..d1b6ddb 100644 --- a/agent-templates/a2a/adapter.py +++ b/agent-templates/a2a/adapter.py @@ -23,11 +23,16 @@ from . import wire_errors as we from .authz import AuthContext, Decision, authorize from .config import ServerConfig, TenantConfig +from .environments import EnvironmentResolver from .session_store import SessionStore #: Resolve a tenant's projection inputs (manifest, roles) from its git ref. RepoResolver = Callable[[TenantConfig], "tuple[dict, dict]"] +#: Resolve a role's ``environment`` BASENAME (never called with an empty/absent one) to +#: a provider environment id, or raise ``environments.EnvironmentResolutionError``. +EnvironmentIdResolver = Callable[[str], str] + class AgentProviderLike(Protocol): def ensure_agent(self, manifest, multiagent=None) -> dict: ... @@ -98,12 +103,20 @@ def __init__( repo_resolver: RepoResolver, *, signer: cg.Signer | None = None, + environment_resolver: EnvironmentIdResolver | None = None, ): self.config = config self.provider = provider self.resolve_repo = repo_resolver self.signer = signer self.store = SessionStore() + # Constructed once per adapter/process (caches both the environments/*.json + # basename->name reads and the environment-ids.json id-state read) — never + # built or re-read per call (FA-14 AC3). Callers that already loaded/mocked an + # id source may pass their own resolver callable instead. + self.resolve_environment_id: EnvironmentIdResolver = ( + environment_resolver if environment_resolver is not None else EnvironmentResolver() + ) # ------------------------------------------------------------------ # # cards @@ -205,9 +218,27 @@ def _resolve_role(self, roles: dict, manifest: dict, tenant: TenantConfig, messa def _provision(self, tenant: TenantConfig, role: dict): agent = self.provider.ensure_agent(role) - environment_id = tenant.provider.environment_id or agent.get("environment_id") + environment_id = self._resolve_environment_id(tenant, role, agent) return agent["id"], agent.get("version"), environment_id + def _resolve_environment_id(self, tenant: TenantConfig, role: dict, agent: dict): + """FA-14 precedence — never silently fall back to null for a NAMED environment: + + 1. ``tenant.provider.environment_id`` (values ``provider.environmentId``) wins + outright — an explicit per-tenant override. + 2. Else, if the role names an ``environment``, resolve it + (``environments.EnvironmentResolver``) — this RAISES rather than returning + ``None`` when the name can't be resolved (AC2). + 3. Else (the role names no environment at all — legitimately no error) fall + back to whatever the provider's ``ensure_agent`` response carried, if any. + """ + if tenant.provider.environment_id: + return tenant.provider.environment_id + env_name = (role or {}).get("environment") + if env_name: + return self.resolve_environment_id(env_name) + return agent.get("environment_id") + def send_message(self, params: dict, ctx: AuthContext) -> dict: task = self._send(params, ctx, streaming=False) return {"task": task} diff --git a/agent-templates/a2a/environments.py b/agent-templates/a2a/environments.py new file mode 100644 index 0000000..fe6c788 --- /dev/null +++ b/agent-templates/a2a/environments.py @@ -0,0 +1,117 @@ +"""Resolve a role's ``environment`` basename to a provider environment id (FA-14). + +Mirrors ``providers/provision.py``'s two-step lookup exactly — do not invent a new +scheme: + +1. ``_env_basename_to_name``: a role's ``environment`` field is the BASENAME (no + ``.json``) of an ``agent-templates/environments/.json`` file (schema: + ``schema/environment.schema.json``); that file's ``name`` field is what batch + provisioning actually registers with the provider. +2. ``env_ids.get(name)``: batch provisioning (``providers/provision.py:apply``) writes + the resulting ``{name: id}`` map to ``/environment-ids.json``. At A2A + dispatch time we do the SAME lookup against that file so ``create_session`` gets a + real ``environment_id`` instead of always ``None`` (``adapter.py`` ``_provision``, + FA-14 AC1). + +Both file reads are cached: the adapter dispatches through this on every SendMessage, so +re-reading disk (a ConfigMap-backed mount, at runtime) per call would be needless I/O. +Construct ONE ``EnvironmentResolver`` per adapter/process — never per request. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + + +class EnvironmentResolutionError(RuntimeError): + """A role names an ``environment`` that could not be resolved to a provider id. + + Raised instead of silently falling back to ``None`` (FA-14 AC2). This is ONLY for a + role that names a SPECIFIC environment which then turns out to be unresolvable — + either the basename has no matching ``environments/.json``, that file has + no ``name``, or its ``name`` has no entry in ``environment-ids.json`` (not yet + provisioned). A role that names NO environment never reaches this class — that case + resolves to ``None`` legitimately, one level up in ``adapter._resolve_environment_id``. + """ + + +#: Sibling of this package (mirrors ``provision.py``'s +#: ``ENV_DIR = os.path.join(TEMPLATES_ROOT, "environments")``, where ``TEMPLATES_ROOT`` +#: is ``agent-templates/`` relative to ``providers/provision.py``; here it is +#: ``agent-templates/`` relative to ``a2a/environments.py``). +_DEFAULT_ENVIRONMENTS_DIR = Path(__file__).resolve().parent.parent / "environments" + +#: Default state mount (a2a-shared chart's ``deploy.stateConfigMap: a2a-state``). +_DEFAULT_STATE_DIR = "/state" + +#: Filename batch provisioning writes the name -> provider-id map to +#: (``providers/provision.py`` ``_write("environment-ids.json", env_ids)``). +ENV_IDS_FILENAME = "environment-ids.json" + + +class EnvironmentResolver: + """Resolve an environment BASENAME -> provider environment id, caching both reads. + + ``environments_dir`` defaults to this repo's own ``agent-templates/environments/`` + (sibling to ``a2a/``); ``state_dir`` defaults to ``$FUZE_STATE_DIR`` or ``/state``. + Both are read lazily, on first use, and held for the resolver's lifetime. + """ + + def __init__( + self, + *, + environments_dir: str | Path | None = None, + state_dir: str | Path | None = None, + ): + self._environments_dir = Path(environments_dir or _DEFAULT_ENVIRONMENTS_DIR) + self._state_dir = Path( + state_dir or os.environ.get("FUZE_STATE_DIR") or _DEFAULT_STATE_DIR + ) + self._ids: dict[str, str] | None = None + self._name_by_basename: dict[str, str] = {} + + def _basename_to_name(self, basename: str) -> str: + if basename in self._name_by_basename: + return self._name_by_basename[basename] + path = self._environments_dir / f"{basename}.json" + try: + doc = json.loads(path.read_text(encoding="utf-8")) + name = doc["name"] + except (OSError, ValueError, KeyError) as exc: + raise EnvironmentResolutionError( + f"role names environment {basename!r} but {path} could not be read as " + f'an environments/*.json with a "name" field: {exc}' + ) from exc + self._name_by_basename[basename] = name + return name + + def _load_ids(self) -> dict[str, str]: + if self._ids is None: + path = self._state_dir / ENV_IDS_FILENAME + try: + self._ids = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + # Absent/unreadable state is fine for roles naming NO environment + # (adapter never calls in here for those); a role naming one will + # raise below via the empty map, per AC2 (never silently null). + self._ids = {} + return self._ids + + def __call__(self, basename: str) -> str: + """Resolve a NAMED environment basename to a provider id, or raise. + + Only call this for a role that DOES name an environment — a role naming none + resolves to ``None`` through a different path and must never reach here. + """ + name = self._basename_to_name(basename) + env_ids = self._load_ids() + env_id = env_ids.get(name) + if not env_id: + raise EnvironmentResolutionError( + f"role names environment {basename!r} (name={name!r}) but it has no " + f"provisioned id in {self._state_dir / ENV_IDS_FILENAME} " + "(has provisioning run for this environment?)" + ) + return env_id diff --git a/agent-templates/a2a/tests/test_environments.py b/agent-templates/a2a/tests/test_environments.py new file mode 100644 index 0000000..e5cc0f0 --- /dev/null +++ b/agent-templates/a2a/tests/test_environments.py @@ -0,0 +1,248 @@ +"""Unit tests for FA-14: resolving a role's ``environment`` -> a provider id. + +Two layers: + +* ``EnvironmentResolver`` (``a2a/environments.py``) — the basename->name->id lookup + itself, its caching, and its errors, exercised directly against tmp_path fixtures + (mirrors ``providers/provision.py``'s ``_env_basename_to_name`` + ``env_ids.get``). +* ``A2AAdapter._provision`` / ``_resolve_environment_id`` — the precedence chain + (tenant override -> resolve(role.environment) -> role names none -> None), and that + an unresolvable NAMED environment raises rather than silently producing ``None``. +""" + +from __future__ import annotations + +import json + +import pytest +from a2a.adapter import A2AAdapter +from a2a.config import ProviderBinding, ServerConfig, TenantConfig +from a2a.environments import EnvironmentResolutionError, EnvironmentResolver + +# --------------------------------------------------------------------------- # +# EnvironmentResolver +# --------------------------------------------------------------------------- # + + +def _write_env_file(envs_dir, basename, name): + envs_dir.mkdir(parents=True, exist_ok=True) + (envs_dir / f"{basename}.json").write_text( + json.dumps({"name": name, "config": {"type": "cloud"}}), encoding="utf-8" + ) + + +def _write_ids(state_dir, ids): + state_dir.mkdir(parents=True, exist_ok=True) + (state_dir / "environment-ids.json").write_text(json.dumps(ids), encoding="utf-8") + + +def test_resolves_known_basename_to_id(tmp_path): + envs_dir = tmp_path / "environments" + state_dir = tmp_path / "state" + _write_env_file(envs_dir, "cloud-product", "fuzeplan-cloud-product") + _write_ids(state_dir, {"fuzeplan-cloud-product": "env-123"}) + + resolver = EnvironmentResolver(environments_dir=envs_dir, state_dir=state_dir) + assert resolver("cloud-product") == "env-123" + + +def test_reads_are_cached_not_reread_per_call(tmp_path, monkeypatch): + envs_dir = tmp_path / "environments" + state_dir = tmp_path / "state" + _write_env_file(envs_dir, "cloud-product", "fuzeplan-cloud-product") + _write_ids(state_dir, {"fuzeplan-cloud-product": "env-123"}) + + resolver = EnvironmentResolver(environments_dir=envs_dir, state_dir=state_dir) + assert resolver("cloud-product") == "env-123" + + # Blow away both files: a cached resolver must not need to re-read them. + (envs_dir / "cloud-product.json").unlink() + (state_dir / "environment-ids.json").unlink() + + assert resolver("cloud-product") == "env-123" + + +def test_unknown_basename_raises_not_none(tmp_path): + resolver = EnvironmentResolver( + environments_dir=tmp_path / "environments", state_dir=tmp_path / "state" + ) + with pytest.raises(EnvironmentResolutionError): + resolver("does-not-exist") + + +def test_name_with_no_provisioned_id_raises_not_none(tmp_path): + envs_dir = tmp_path / "environments" + state_dir = tmp_path / "state" + _write_env_file(envs_dir, "cloud-product", "fuzeplan-cloud-product") + _write_ids(state_dir, {"some-other-env": "env-999"}) # our name absent + + resolver = EnvironmentResolver(environments_dir=envs_dir, state_dir=state_dir) + with pytest.raises(EnvironmentResolutionError): + resolver("cloud-product") + + +def test_missing_state_dir_still_raises_for_a_named_environment(tmp_path): + envs_dir = tmp_path / "environments" + _write_env_file(envs_dir, "cloud-product", "fuzeplan-cloud-product") + # state_dir deliberately never created / never written to. + + resolver = EnvironmentResolver( + environments_dir=envs_dir, state_dir=tmp_path / "state-does-not-exist" + ) + with pytest.raises(EnvironmentResolutionError): + resolver("cloud-product") + + +# --------------------------------------------------------------------------- # +# A2AAdapter provisioning precedence (FA-14 AC2) +# --------------------------------------------------------------------------- # + + +class _FakeProvider: + def ensure_agent(self, manifest, multiagent=None): + return {"name": manifest.get("role", "x"), "id": "agent-1", "version": "1"} + + def create_session( + self, agent_id, version, environment_id, vault_ids=None, memory_resources=None, title=None + ): + return "sess-1" + + +def _cfg_and_tenant(*, environment_id=None): + tenant = TenantConfig( + tenant="FuzePlan", + repo="izzywdev/FuzePlan", + enabled=True, + provider=ProviderBinding(name="fake", environment_id=environment_id), + ) + cfg = ServerConfig(enabled=True, tenants=(tenant,)) + return cfg, tenant + + +def _noop_resolver(_tenant): + return {}, {} + + +def test_role_naming_environment_resolves_to_id(): + cfg, tenant = _cfg_and_tenant() + calls = [] + + def env_resolver(basename): + calls.append(basename) + return "env-resolved-1" + + a = A2AAdapter(cfg, _FakeProvider(), _noop_resolver, environment_resolver=env_resolver) + role = {"role": "product-manager", "environment": "cloud-product"} + + agent_id, version, environment_id = a._provision(tenant, role) + + assert environment_id == "env-resolved-1" + assert calls == ["cloud-product"] # never null, resolved via the role's env + + +def test_tenant_provider_environment_id_takes_precedence(): + cfg, tenant = _cfg_and_tenant(environment_id="tenant-env-override") + + def env_resolver(basename): + raise AssertionError("must not be called: tenant.provider.environment_id wins") + + a = A2AAdapter(cfg, _FakeProvider(), _noop_resolver, environment_resolver=env_resolver) + role = {"role": "product-manager", "environment": "cloud-product"} + + _, _, environment_id = a._provision(tenant, role) + + assert environment_id == "tenant-env-override" + + +def test_role_naming_no_environment_resolves_to_none_legitimately(): + cfg, tenant = _cfg_and_tenant() + + def env_resolver(basename): + raise AssertionError("must not be called: role names no environment") + + a = A2AAdapter(cfg, _FakeProvider(), _noop_resolver, environment_resolver=env_resolver) + role = {"role": "product-manager"} # no "environment" key at all + + _, _, environment_id = a._provision(tenant, role) + + assert environment_id is None + + +def test_unresolvable_named_environment_raises_never_null(): + cfg, tenant = _cfg_and_tenant() + + def env_resolver(basename): + raise EnvironmentResolutionError(f"no such environment: {basename}") + + a = A2AAdapter(cfg, _FakeProvider(), _noop_resolver, environment_resolver=env_resolver) + role = {"role": "product-manager", "environment": "totally-unknown"} + + with pytest.raises(EnvironmentResolutionError): + a._provision(tenant, role) + + +def test_default_environment_resolver_is_shared_env_resolver_instance(): + """Constructing an adapter without ``environment_resolver`` still wires a real, + cached ``EnvironmentResolver`` (built once at __init__, not read per call).""" + cfg, tenant = _cfg_and_tenant() + a = A2AAdapter(cfg, _FakeProvider(), _noop_resolver) + assert isinstance(a.resolve_environment_id, EnvironmentResolver) + + +# --------------------------------------------------------------------------- # +# End-to-end: send_message actually passes the resolved id into create_session +# --------------------------------------------------------------------------- # + + +def test_send_message_passes_resolved_environment_id_to_create_session(fuzeplan_repo): + from a2a.authz import AuthContext + from a2a.loader import load_repo + + manifest, roles = load_repo(fuzeplan_repo) + roles = dict(roles) + roles["product-manager"] = {**roles["product-manager"], "environment": "cloud-product"} + + def resolver(_tenant): + return manifest, roles + + class RecordingProvider(_FakeProvider): + def __init__(self): + self.seen_environment_ids = [] + + def create_session( + self, agent_id, version, environment_id, vault_ids=None, memory_resources=None, + title=None, + ): + self.seen_environment_ids.append(environment_id) + return "sess-1" + + def run_until_block(self, session_id, prompt=None): + return {"text": "done", "status": "idle", "pending": None} + + cfg = ServerConfig( + enabled=True, + tenants=( + TenantConfig( + tenant="FuzePlan", + repo="izzywdev/FuzePlan", + enabled=True, + provider=ProviderBinding(name="fake"), + ), + ), + ) + prov = RecordingProvider() + a = A2AAdapter(cfg, prov, resolver, environment_resolver=lambda basename: "env-e2e-1") + + params = { + "tenant": "FuzePlan", + "message": { + "messageId": "m1", + "role": "ROLE_USER", + "parts": [{"text": "hi"}], + "metadata": {"skillId": "product-manager"}, + }, + } + out = a.send_message(params, AuthContext(caller="FuzeSales")) + + assert out["task"]["status"]["state"] == "TASK_STATE_COMPLETED" + assert prov.seen_environment_ids == ["env-e2e-1"] # never None/null diff --git a/agent-templates/environments/cloud-orchestrator.json b/agent-templates/environments/cloud-orchestrator.json new file mode 100644 index 0000000..dd82ed3 --- /dev/null +++ b/agent-templates/environments/cloud-orchestrator.json @@ -0,0 +1,17 @@ +{ + "$schema": "../schema/environment.schema.json", + "name": "fuzeagent-cloud-orchestrator", + "config": { + "type": "cloud", + "packages": { + "apt": ["jq", "gh"], + "pip": ["requests", "httpx", "pyyaml"] + }, + "networking": { + "type": "limited", + "allowed_hosts": [], + "allow_mcp_servers": true, + "allow_package_managers": true + } + } +} diff --git a/agent-templates/roles/agent-orchestrator/role.json b/agent-templates/roles/agent-orchestrator/role.json index 311cf82..0756e0c 100644 --- a/agent-templates/roles/agent-orchestrator/role.json +++ b/agent-templates/roles/agent-orchestrator/role.json @@ -5,6 +5,7 @@ "name": "FuzeAgent agent-orchestrator", "description": "Accepts a delegated goal and runs a managed Claude agent to accomplish it, returning the outcome and any artifacts. FuzeAgent selects and dispatches the appropriate managed-agent role, drives it to completion (pausing for human approval on always_ask/production actions), and hands the result back — the caller needs no agent-runtime knowledge, tools, or credentials of its own.", "model": "claude-opus-4-8", + "environment": "cloud-orchestrator", "skills": [], "services": { "github": "none", "k8s": "none", "cloud": "none" }, "a2a": { From bfd66f183c0d9f4d8a1a756b5477f947f30e7d27 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 08:01:23 +0000 Subject: [PATCH 2/2] chore(governance): reconcile managed files to FuzeSDLC v1 [skip ci] --- .claude/agents/backend-engineer.md | 2 +- .claude/agents/frontend-engineer.md | 2 +- .../schema/role-manifest.schema.json | 34 +++++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/.claude/agents/backend-engineer.md b/.claude/agents/backend-engineer.md index dbcc18d..a4bcb03 100644 --- a/.claude/agents/backend-engineer.md +++ b/.claude/agents/backend-engineer.md @@ -4,7 +4,7 @@ model: sonnet description: Implements ONLY the backend slice of a feature — HTTP API/services, business logic, DB schema/migrations, events, and the backend's own unit tests — against a frozen API contract. Does NOT build UI, the independent test suite, deploy wiring, or docs. Use for backend implementation in a contract-first fan-out. # Figma is reserved for frontend-engineer; pure-code agent gets core tools only (no MCP). tools: Task, Bash, Glob, Grep, LS, Read, Edit, MultiEdit, Write, NotebookEdit, WebFetch, WebSearch, TodoWrite -skills: [api-contract-first, feature-flags, verification-protocol, model-cascade] +skills: [api-contract-first, feature-flags, logging, verification-protocol, model-cascade] --- You are a **backend engineer** for FuzeFront. You implement the **backend slice only**. diff --git a/.claude/agents/frontend-engineer.md b/.claude/agents/frontend-engineer.md index 2ccbc44..3e8d6a0 100644 --- a/.claude/agents/frontend-engineer.md +++ b/.claude/agents/frontend-engineer.md @@ -5,7 +5,7 @@ description: Implements ONLY the UI slice of a feature — a design-system-first # SOLE owner of the Figma MCP plugin (design-to-code). All other domain agents have # Figma removed from their tool grant — it is reserved here for the UI/design-system slice. tools: "*" -skills: [fuzefront-ui-package, design-system-inheritance, design-system-conformance, ui-frame-contract, frontend-design, feature-flags, ui-runtime-validation, verification-protocol, model-cascade] +skills: [fuzefront-ui-package, design-system-inheritance, design-system-conformance, ui-frame-contract, frontend-design, feature-flags, logging, ui-runtime-validation, verification-protocol, model-cascade] --- You are a **frontend engineer**. You implement the **UI slice only**. diff --git a/agent-templates/schema/role-manifest.schema.json b/agent-templates/schema/role-manifest.schema.json index 8780983..f10670f 100644 --- a/agent-templates/schema/role-manifest.schema.json +++ b/agent-templates/schema/role-manifest.schema.json @@ -80,6 +80,40 @@ "metadata": { "type": "object", "description": "Passed through as agent `metadata` (free-form tracking)." + }, + "a2a": { + "type": "object", + "additionalProperties": false, + "description": "OPTIONAL A2A discoverability/publication block. Mirrors the frozen contract FuzeAgent/agent-templates/contracts/a2a/v1/schema/role-a2a-extension.schema.json. Every field has a derived default, so no existing role.json needs it. The card projection reads role/name/description/services/metadata/coordinator regardless; this block only lets a role improve discoverability (examples/tags) or opt out of publication.", + "properties": { + "publish": { + "type": "boolean", + "default": true, + "description": "false hides this role from the public card. Still reachable on the EXTENDED card if the caller is allowlisted (authz.md §5)." + }, + "extendedOnly": { + "type": "boolean", + "default": false, + "description": "true publishes this skill ONLY on the authenticated extended card, never on the anonymous /.well-known/agent-card.json. Use for skills whose mere existence is sensitive." + }, + "tags": { + "type": "array", + "items": { "type": "string" }, + "description": "Extra tags merged with the derived tags. Derived tags are never removed." + }, + "examples": { + "type": "array", + "items": { "type": "string" }, + "description": "Example prompts a caller can send to this skill — the primary signal a calling agent uses to decide fit. Absent examples make a skill effectively undiscoverable." + }, + "inputModes": { "type": "array", "items": { "type": "string" } }, + "outputModes": { "type": "array", "items": { "type": "string" } }, + "scopes": { + "type": "array", + "items": { "type": "string" }, + "description": "OAuth scopes required for THIS skill, projected into the skill's securityRequirements." + } + } } } }