Skip to content
Open
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
13 changes: 13 additions & 0 deletions agent/src/registry/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Agent asset registry client + reference grammar (#246).

The agent is a *read-only* consumer of the registry: it receives already-resolved
assets in its task payload and, where it needs to look one up directly, talks to
the substrate through the ``RegistryClient`` port (never a raw AWS SDK client).

Public surface:
- ``parse_ref`` / ``ParsedRef`` (``ref``) — the strict ``registry://`` grammar,
mirrored byte-for-byte by ``cdk/src/handlers/shared/registry/ref.ts`` and the
``contracts/registry-resolution/`` parity corpus.

See ``docs/design/REGISTRY.md`` and ``ISSUE_246_AGENTCORE_FINDINGS.md``.
"""
144 changes: 144 additions & 0 deletions agent/src/registry/agentcore_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""Read-side AgentCore implementation of the ``RegistryClient`` port (#246).

The agent only reads: ``get_record`` and ``resolve``. This is the one Python file
that talks to the AgentCore control plane (via boto3) — everything upstream uses
the port, so a substrate swap is confined here. Mirrors the read half of
``cdk/src/handlers/shared/registry/agentcore-client.ts``.
"""

from __future__ import annotations

import json
import re
from typing import TYPE_CHECKING, Any

from registry.client import RegistryResolutionError, ResolvedAsset
from registry.resolver import select_highest

if TYPE_CHECKING:
from registry.ref import ParsedRef

_NAME_SEP = "/"
# Option-A record name is `kind/namespace/name`; the name may itself contain `/`.
_NAME_MIN_PARTS = 2
# The reverse-DNS key under which ABCA runtime config rides in a native `_meta`
# block (matches RUNTIME_META_KEY in registry/types.ts).
_RUNTIME_META_KEY = "dev.abca.runtime"
# Frontmatter key carrying the runtime payload (JSON) in a native AGENT_SKILLS
# SKILL.md — mirrors SKILL_RUNTIME_FM_KEY in registry/agentcore-client.ts.
_SKILL_RUNTIME_FM_KEY = "x-abca-runtime"
_SKILL_RUNTIME_RE = re.compile(rf"^{_SKILL_RUNTIME_FM_KEY}:\s*'(.+)'\s*$", re.MULTILINE)
_RESOLVABLE_STATUSES = ("APPROVED", "DEPRECATED")


class AgentCoreRegistryClient:
"""Read-only registry access backed by AgentCore."""

def __init__(self, registry_id: str, client: Any) -> None:
# ``client`` is a boto3 ``bedrock-agentcore-control`` client, injected so
# the agent's scoped-session helper (aws_session) owns credential wiring.
self._registry_id = registry_id
self._client = client

# --- name (Option A) decode -------------------------------------------------

@staticmethod
def _decode_name(record_name: str) -> tuple[str, str, str]:
parts = record_name.split(_NAME_SEP)
kind = parts[0] if parts else ""
namespace = parts[1] if len(parts) > 1 else ""
name = _NAME_SEP.join(parts[_NAME_MIN_PARTS:]) if len(parts) > _NAME_MIN_PARTS else ""
return kind, namespace, name

@staticmethod
def _id_from_arn(arn: str) -> str:
return arn.split("/")[-1] if "/" in arn else arn

# --- record extraction ------------------------------------------------------

def _extract_runtime(self, raw: dict[str, Any]) -> dict[str, Any]:
descriptors = raw.get("descriptors", {}) or {}
descriptor_type = raw.get("descriptorType")
if descriptor_type == "CUSTOM":
body = json.loads(descriptors.get("custom", {}).get("inlineContent", "{}"))
return body.get("runtime", {})
if descriptor_type == "AGENT_SKILLS":
# SKILL.md is Markdown frontmatter, not JSON — recover the runtime
# from the `x-abca-runtime` frontmatter key (mirrors the TS adapter).
skill_md = (
descriptors.get("agentSkills", {}).get("skillMd", {}).get("inlineContent", "")
)
m = _SKILL_RUNTIME_RE.search(skill_md)
return json.loads(m.group(1)) if m else {}
# MCP: JSON server.json with the runtime in a `_meta` block.
inline = descriptors.get("mcp", {}).get("server", {}).get("inlineContent") or "{}"
body = json.loads(inline)
return body.get("_meta", {}).get(_RUNTIME_META_KEY, {})

def _list_records(self, kind: str, namespace: str) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
next_token: str | None = None
while True:
kwargs: dict[str, Any] = {"registryId": self._registry_id, "maxResults": 50}
if next_token:
kwargs["nextToken"] = next_token
page = self._client.list_registry_records(**kwargs)
for summary in page.get("registryRecords", []) or []:
dkind, dns, _ = self._decode_name(summary.get("name", ""))
if dkind != kind or dns != namespace:
continue
record_id = (
self._id_from_arn(summary["recordArn"])
if summary.get("recordArn")
else summary.get("recordId")
)
if not record_id:
continue
full = self._client.get_registry_record(
registryId=self._registry_id, recordId=record_id
)
out.append(full)
next_token = page.get("nextToken")
if not next_token:
break
return out

# --- port surface -----------------------------------------------------------

def get_record(
self, kind: str, namespace: str, name: str, version: str
) -> dict[str, Any] | None:
for raw in self._list_records(kind, namespace):
_, _, dname = self._decode_name(raw.get("name", ""))
if dname == name and raw.get("recordVersion") == version:
return raw
return None

def resolve(self, ref: ParsedRef) -> ResolvedAsset:
ref_str = f"registry://{ref.kind}/{ref.namespace}/{ref.name}@{ref.constraint.raw}"
records = self._list_records(ref.kind, ref.namespace)
candidates = [
r
for r in records
if self._decode_name(r.get("name", ""))[2] == ref.name
and r.get("status") in _RESOLVABLE_STATUSES
]
by_version = {r.get("recordVersion", ""): r for r in candidates}
winning = select_highest(list(by_version.keys()), ref.constraint)
if winning is None:
raise RegistryResolutionError(
"NO_MATCHING_VERSION",
ref_str,
f"no approved version of {ref.kind}/{ref.namespace}/{ref.name} "
f"satisfies {ref.constraint.raw}",
)
winner = by_version[winning]
warnings = ["DEPRECATED"] if winner.get("status") == "DEPRECATED" else []
return ResolvedAsset(
kind=ref.kind,
namespace=ref.namespace,
name=ref.name,
version=winning,
runtime=self._extract_runtime(winner),
warnings=warnings,
)
60 changes: 60 additions & 0 deletions agent/src/registry/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""The read-side ``RegistryClient`` port for the agent (#246).

The agent is a read-only consumer: it resolves refs and fetches records, but never
publishes or governs. It talks to the substrate through this Protocol, never a raw
AWS SDK client — the one implementation is ``AgentCoreRegistryClient``
(``registry.agentcore_client``), so a substrate swap is confined there.

The write-side verbs (publish / submit / approve) live only on the TypeScript port
(``cdk/src/handlers/shared/registry/client.ts``); the agent has no business
mutating the registry.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Protocol

if TYPE_CHECKING:
from registry.ref import ParsedRef


@dataclass(frozen=True)
class ResolvedAsset:
"""One resolved asset: enough to load it without knowing where bytes live."""

kind: str
namespace: str
name: str
version: str
runtime: dict[str, Any]
warnings: list[str] = field(default_factory=list)


class RegistryResolutionError(Exception):
"""Raised when a ref cannot be resolved. ``reason`` matches the TS token set
(``NO_MATCHING_VERSION`` / ``REMOVED`` / ``INVALID_CONSTRAINT`` /
``INVALID_REGISTRY_REF``) so both languages agree on *why*."""

def __init__(self, reason: str, ref: str, message: str) -> None:
super().__init__(message)
self.reason = reason
self.ref = ref


class RegistryClient(Protocol):
"""Read-only registry access. See the TS port for the full (write) surface."""

def get_record(
self, kind: str, namespace: str, name: str, version: str
) -> dict[str, Any] | None:
"""Fetch a single record by exact coordinates, or ``None`` if absent."""
...

def resolve(self, ref: ParsedRef) -> ResolvedAsset:
"""Resolve a parsed ref to a single APPROVED (or DEPRECATED+warn) asset.

Fail-closed: raises ``RegistryResolutionError`` with a specific reason on
any unresolved ref.
"""
...
110 changes: 110 additions & 0 deletions agent/src/registry/ref.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""Strict ``registry://`` reference grammar for the agent asset registry (#246).

::

registry://<kind>/<namespace>/<name>@<constraint>
kind = [a-z][a-z0-9_]* snake_case: mcp_server, cedar_policy_module
namespace = [a-z][a-z0-9-]*
name = [a-z0-9][a-z0-9._-]*
constraint = [^~]?MAJOR.MINOR.PATCH[-prerelease] exact / caret / tilde only

The ``@<constraint>`` pin is MANDATORY (fail-closed: no implicit "latest").

This module mirrors ``cdk/src/handlers/shared/registry/ref.ts`` byte-for-byte and
is exercised by the ``contracts/registry-resolution/`` parity corpus. Keep the two
in lockstep — a change here without the matching TS change (or vice versa) is a
parity break that CI must catch.
"""

from __future__ import annotations

import re
from dataclasses import dataclass

# MVP asset kinds the registry loads end-to-end or stages.
REGISTRY_KINDS = ("mcp_server", "cedar_policy_module", "skill")
# Reserved kinds accepted by the grammar but rejected at publish (no loader yet).
RESERVED_KINDS = ("plugin", "subagent", "prompt_fragment", "capability")

# Structural split — scheme + 3 path segments + the (mandatory) constraint.
# ``\Z`` (absolute end of string), not ``$``: Python's ``$`` also matches just
# before a trailing newline, so ``$`` would accept ``…@1.0.0\n`` that the JS
# mirror (ref.ts, no ``m`` flag) rejects — a byte-for-byte parity break.
_REF_SHAPE = re.compile(
r"^registry://([a-z][a-z0-9_]*)/([a-z][a-z0-9-]*)/([a-z0-9][a-z0-9._-]*)@(.+)\Z"
)
# exact / caret / tilde over MAJOR.MINOR.PATCH with an optional prerelease.
# Rejects ``*``, ``latest``, ``>=``, ``<=``, x-ranges, and bare prerelease modifiers.
_CONSTRAINT = re.compile(
r"^([\^~]?)(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?\Z"
)
_OP_BY_PREFIX = {"": "exact", "^": "caret", "~": "tilde"}


class RefError(ValueError):
"""Raised when a ``registry://`` ref is malformed.

``reason`` is one of ``INVALID_REGISTRY_REF`` / ``INVALID_CONSTRAINT`` — the
same reason tokens the TS resolver reports, so both sides agree on *why* a
ref failed, not just *that* it failed.
"""

def __init__(self, reason: str, message: str) -> None:
super().__init__(message)
self.reason = reason


@dataclass(frozen=True)
class ParsedConstraint:
op: str # exact | caret | tilde
major: int
minor: int
patch: int
prerelease: str | None
raw: str


@dataclass(frozen=True)
class ParsedRef:
kind: str
namespace: str
name: str
constraint: ParsedConstraint


def parse_constraint(raw: str) -> ParsedConstraint | None:
"""Parse + validate a constraint string in isolation. ``None`` if invalid."""
m = _CONSTRAINT.match(raw)
if not m:
return None
prefix, major, minor, patch, prerelease = m.groups()
return ParsedConstraint(
op=_OP_BY_PREFIX[prefix],
major=int(major),
minor=int(minor),
patch=int(patch),
prerelease=prerelease,
raw=raw,
)


def parse_ref(ref: str) -> ParsedRef:
"""Parse a strict ``registry://kind/namespace/name@constraint`` reference.

Raises ``RefError`` (with a ``reason``) on a malformed ref or a floating /
unsupported constraint — pins are mandatory.
"""
shape = _REF_SHAPE.match(ref)
if not shape:
raise RefError(
"INVALID_REGISTRY_REF",
f"not a valid registry ref (expected registry://kind/namespace/name@constraint): {ref}",
)
kind, namespace, name, raw_constraint = shape.groups()
constraint = parse_constraint(raw_constraint)
if constraint is None:
raise RefError(
"INVALID_CONSTRAINT",
f"unsupported version constraint '{raw_constraint}' (use exact, ^, or ~)",
)
return ParsedRef(kind=kind, namespace=namespace, name=name, constraint=constraint)
Loading
Loading