agentenv-framework-protocol 0.1.269__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,185 @@
1
+ """Data-plane wire protocol client (JSON-RPC) for servers implementing the AgentEnv protocol."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+
6
+ import httpx
7
+
8
+ from .manifest import INTERFACE_MANIFEST_PATH
9
+ from .types import INTAKE_EXTENSION_URI, MCP_PATH, MCP_TRANSPORT, METHOD_ADD, METHOD_GET, METHOD_RESET, RPC_PATH, WELL_KNOWN_PATH, AddDataResponse, GetDataResponse, Part, ResetDataResponse
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ async def _rpc(base_url: str, method: str, params: dict, timeout: int, verify: bool) -> dict:
15
+ request = {"jsonrpc": "2.0", "id": 1, "method": method, "params": params}
16
+ async with httpx.AsyncClient(verify=verify) as client:
17
+ response = await client.post(f"{base_url}{RPC_PATH}", json=request, timeout=timeout)
18
+ response.raise_for_status()
19
+ body = response.json()
20
+ if "error" in body:
21
+ error = body["error"]
22
+ raise RuntimeError(f"{method} failed: {error.get('message')} ({error.get('data') or error.get('code')})")
23
+ return body["result"]
24
+
25
+
26
+ async def reset_data(base_url: str, timeout: int = 30, verify: bool = True) -> ResetDataResponse:
27
+ return ResetDataResponse.model_validate(await _rpc(base_url, METHOD_RESET, {}, timeout, verify))
28
+
29
+
30
+ async def add_data(base_url: str, parts: list[Part], timeout: int = 120, verify: bool = True) -> AddDataResponse:
31
+ params = {"parts": [p.model_dump(mode="json", exclude_none=True) for p in parts]}
32
+ return AddDataResponse.model_validate(await _rpc(base_url, METHOD_ADD, params, timeout, verify))
33
+
34
+
35
+ async def get_data(base_url: str, timeout: int = 30, verify: bool = True) -> GetDataResponse:
36
+ return GetDataResponse.model_validate(await _rpc(base_url, METHOD_GET, {}, timeout, verify))
37
+
38
+
39
+ async def get_card(base_url: str, timeout: int = 10, verify: bool = True) -> dict:
40
+ async with httpx.AsyncClient(verify=verify) as client:
41
+ response = await client.get(f"{base_url}{WELL_KNOWN_PATH}", timeout=timeout)
42
+ response.raise_for_status()
43
+ return response.json()
44
+
45
+
46
+ async def supports_v1(base_url: str, verify: bool = True) -> bool:
47
+ try:
48
+ await get_card(base_url, verify=verify)
49
+ return True
50
+ except httpx.HTTPStatusError as e:
51
+ if e.response.status_code != 404:
52
+ logger.warning(f"v1 probe {base_url}: unexpected {e.response.status_code}, treating as legacy")
53
+ return False
54
+ except Exception as e:
55
+ logger.warning(f"v1 probe {base_url}: {type(e).__name__}, treating as legacy")
56
+ return False
57
+
58
+
59
+ def find_extension(card: dict, uri: str) -> dict | None:
60
+ """Return the EnvironmentCard extension advertised under `uri`, or None.
61
+
62
+ Extensions live under `capabilities.extensions` (mirroring A2A's AgentCard). Tolerates a
63
+ missing/null `capabilities` or `extensions` so legacy and no-extensions cards return None
64
+ rather than raising.
65
+ """
66
+ for ext in (card.get("capabilities") or {}).get("extensions") or []:
67
+ if ext.get("uri") == uri:
68
+ return ext
69
+ return None
70
+
71
+
72
+ def extension_params(card: dict, uri: str) -> dict:
73
+ """Return the `params` of the extension at `uri`, or {} if absent or paramless."""
74
+ ext = find_extension(card, uri)
75
+ return (ext.get("params") or {}) if ext else {}
76
+
77
+
78
+ def find_extension_method(card: dict, uri: str, method: str) -> dict | None:
79
+ """Return the method named `method` that the extension at `uri` advertises, or None.
80
+
81
+ `method` names an entry of the extension's `params.methods`. The returned entry's own `method`
82
+ key is its HTTP verb (POST unless advertised), and its `endpoint` is the method's own, else the
83
+ extension's.
84
+ """
85
+ advertised = extension_params(card, uri)
86
+ entry = (advertised.get("methods") or {}).get(method)
87
+ if entry is None:
88
+ return None
89
+ return {**entry, "endpoint": entry.get("endpoint") or advertised.get("endpoint"), "method": (entry.get("method") or "POST").upper()}
90
+
91
+
92
+ def mcp_path(card: dict) -> str:
93
+ """Return the path of the MCP endpoint a card declares, or `MCP_PATH` by convention.
94
+
95
+ Paths are relative to the address the card was fetched from.
96
+ """
97
+ for interface in card.get("additionalInterfaces") or []:
98
+ if interface.get("transport") == MCP_TRANSPORT and interface.get("url"):
99
+ return interface["url"]
100
+ return MCP_PATH
101
+
102
+
103
+ def intake_declaration(card: dict) -> dict | None:
104
+ """Return the intake declaration advertised under `INTAKE_EXTENSION_URI`, or None.
105
+
106
+ Distinct from `extension_params`: absence of the extension (or its `params`) returns None —
107
+ "no claim", so consumers skip fit-checks — rather than `{}`, which would read as "declares an
108
+ empty intake". Mirrors the `supports_v1` 404->legacy tolerance.
109
+ """
110
+ ext = find_extension(card, INTAKE_EXTENSION_URI)
111
+ if not ext:
112
+ return None
113
+ params = ext.get("params")
114
+ return params if params else None
115
+
116
+
117
+ def find_tool(card: dict, name: str) -> dict | None:
118
+ """Return the MCP tool advertised under `name`, or None.
119
+
120
+ `capabilities.tools` is advertisement-only (invocation stays MCP). Tolerates a missing/null
121
+ `capabilities` or `tools` so legacy and no-tools cards return None rather than raising.
122
+ """
123
+ for entry in (card.get("capabilities") or {}).get("tools") or []:
124
+ if entry.get("name") == name:
125
+ return entry
126
+ return None
127
+
128
+
129
+ def find_child(card: dict, name: str) -> dict | None:
130
+ """Return the nested child EnvironmentCard named `name`, or None.
131
+
132
+ A composed card (e.g. a gateway fronting one or more MCP servers) nests each backing
133
+ environment's card under `children_environments`, with that child's endpoints already
134
+ rewritten to be reachable from the composed card's base_url. Callers resolve a service by
135
+ name here, then use the child card with `find_extension`/`invoke_extension`. Tolerates a
136
+ missing/null `children_environments` so single/leaf cards return None rather than raising.
137
+ """
138
+ for child in card.get("children_environments") or []:
139
+ if child.get("name") == name:
140
+ return child
141
+ return None
142
+
143
+
144
+ async def get_interface_manifest(base_url: str, interface: str, endpoint: str = INTERFACE_MANIFEST_PATH, timeout: int = 30, verify: bool = True):
145
+ """Fetch one interface's manifest from a server's manifest index endpoint.
146
+
147
+ ``endpoint`` is the index path — pass the one advertised on the card under
148
+ ``GET_INTERFACES_EXTENSION_URI`` (already gateway-rewritten) or leave the
149
+ default when talking to a server directly. Mechanical by design: raises
150
+ ``httpx.HTTPStatusError`` on a non-2xx response and ``ValueError`` on a
151
+ non-JSON body; treating a 404 as "server opted out" is caller policy.
152
+ """
153
+ url = f"{base_url.rstrip('/')}{endpoint}/{interface}"
154
+ async with httpx.AsyncClient(verify=verify) as client:
155
+ response = await client.get(url, timeout=timeout)
156
+ response.raise_for_status()
157
+ return response.json()
158
+
159
+
160
+ async def invoke_extension(base_url: str, card: dict, uri: str, params: dict | None = None, timeout: int = 30, verify: bool = True, *, method: str | None = None):
161
+ """Invoke a card extension via its advertised REST endpoint.
162
+
163
+ Reads the endpoint and HTTP verb of the extension method named `method` from the card, so the
164
+ card alone is enough to invoke; without `method`, the first method listed. Returns the parsed
165
+ JSON result; raises on a non-2xx response.
166
+ """
167
+ ext = find_extension(card, uri)
168
+ if ext is None:
169
+ raise ValueError(f"extension not advertised on card: {uri}")
170
+ advertised = ext.get("params") or {}
171
+ name = method if method is not None else next(iter(advertised.get("methods") or {}), None)
172
+ op = find_extension_method(card, uri, name) if name is not None else {"endpoint": advertised.get("endpoint"), "method": "POST"}
173
+ if op is None:
174
+ offered = ", ".join(advertised.get("methods") or {}) or "none"
175
+ raise ValueError(f"extension {uri} does not advertise method {method!r} (advertises: {offered})")
176
+ if not op["endpoint"]:
177
+ raise ValueError(f"extension {uri} has no endpoint in params")
178
+ url = f"{base_url}{op['endpoint']}"
179
+ async with httpx.AsyncClient(verify=verify) as client:
180
+ if op["method"] == "GET":
181
+ response = await client.get(url, params=params or {}, timeout=timeout)
182
+ else:
183
+ response = await client.request(op["method"], url, json=params or {}, timeout=timeout)
184
+ response.raise_for_status()
185
+ return response.json()
@@ -0,0 +1,203 @@
1
+ """Interface Manifest: a structured description of a server's data model that
2
+ interface renderers (CLI, GUI, ...) are generated from.
3
+
4
+ Servers build manifests from their API spec, validate them against their live
5
+ MCP tools, and serve them at ``INTERFACE_MANIFEST_PATH``, advertised on the
6
+ EnvironmentCard under ``GET_INTERFACES_EXTENSION_URI``. Producers and
7
+ consumers both import the schema, constants, and version rules from here.
8
+
9
+ Two layers: a structural core (:class:`InterfaceManifest`) and one projection
10
+ per interface (:class:`CliManifest` for the CLI). Manifests are structure
11
+ only — no environment data or per-environment behavior — and every operation
12
+ references an MCP tool by name, so renderers interact with a server purely
13
+ through the protocol and tool calls.
14
+
15
+ Skew tolerance: models ignore unknown fields, and :func:`manifest_compatible`
16
+ permits compatible version skew, so producers and consumers can deploy
17
+ independently.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from typing import Dict, List, Literal, Optional
24
+
25
+ from pydantic import BaseModel, ConfigDict, Field
26
+
27
+ # Bumped when the manifest shape changes in a way renderers must notice.
28
+ MANIFEST_VERSION = "0.1.0"
29
+
30
+ # Where a server serves its manifest index (GET -> list of interface names);
31
+ # one interface's manifest is at f"{INTERFACE_MANIFEST_PATH}/{interface}".
32
+ INTERFACE_MANIFEST_PATH = "/agentenv/interface-manifest"
33
+
34
+ # EnvironmentCard extension advertising the (gateway-rewritten) index endpoint
35
+ # in `params.endpoint`. Absent = pre-extension image; `[]` from the endpoint =
36
+ # definitively no manifests.
37
+ GET_INTERFACES_EXTENSION_URI = "urn:agentenv:get-interfaces/v1"
38
+
39
+ OperationKind = Literal["list", "read", "create", "update", "delete"]
40
+
41
+ # CLI command name per core operation kind (``read`` renders as ``get``).
42
+ CLI_COMMANDS: Dict[str, str] = {
43
+ "list": "list",
44
+ "read": "get",
45
+ "create": "create",
46
+ "update": "update",
47
+ "delete": "delete",
48
+ }
49
+
50
+ _VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
51
+
52
+
53
+ def manifest_compatible(served: object, supported: str = MANIFEST_VERSION) -> bool:
54
+ """Whether a manifest at version ``served`` is consumable by a renderer built for ``supported``.
55
+
56
+ Same major is compatible; while the major is 0 (pre-1.0 semver) the minor
57
+ must match too, so only patch-level skew is tolerated. A missing or
58
+ unparseable version is incompatible.
59
+ """
60
+ if not isinstance(served, str):
61
+ return False
62
+ served_match = _VERSION_RE.match(served)
63
+ supported_match = _VERSION_RE.match(supported)
64
+ if not served_match or not supported_match:
65
+ return False
66
+ if served_match.group(1) != supported_match.group(1):
67
+ return False
68
+ if served_match.group(1) == "0" and served_match.group(2) != supported_match.group(2):
69
+ return False
70
+ return True
71
+
72
+
73
+ class ManifestModel(BaseModel):
74
+ """Base for all manifest models: unknown fields from a newer producer are ignored."""
75
+
76
+ model_config = ConfigDict(extra="ignore")
77
+
78
+
79
+ class FieldSpec(ManifestModel):
80
+ """One field of an entity, as seen through the server's tools."""
81
+
82
+ name: str
83
+ type: str
84
+ format: Optional[str] = None
85
+ enum: Optional[List[str]] = None
86
+ description: Optional[str] = None
87
+ required: bool = False
88
+ read_only: bool = False
89
+
90
+
91
+ class Relationship(ManifestModel):
92
+ """A field that points at another entity (for detail-view linking)."""
93
+
94
+ field: str
95
+ references: str
96
+ many: bool = False
97
+
98
+
99
+ class ParamSpec(ManifestModel):
100
+ """A single input parameter of an operation/action, as the tool accepts it.
101
+
102
+ Carries everything an interface projection needs to render the input
103
+ without re-reading the spec: its type, array item type, whether it is
104
+ required, and (for enum fields) the constrained set of allowed values.
105
+ """
106
+
107
+ name: str
108
+ type: str
109
+ item_type: Optional[str] = None
110
+ required: bool = False
111
+ enum: Optional[List[str]] = None
112
+
113
+
114
+ class Operation(ManifestModel):
115
+ """A CRUD operation on an entity, backed by exactly one MCP tool."""
116
+
117
+ kind: OperationKind
118
+ tool: str
119
+ summary: Optional[str] = None
120
+ description: Optional[str] = None
121
+ required: List[str] = Field(default_factory=list)
122
+ params: List[ParamSpec] = Field(default_factory=list)
123
+
124
+
125
+ class Action(ManifestModel):
126
+ """A non-CRUD tool (rendered as a button/command), backed by one MCP tool."""
127
+
128
+ tool: str
129
+ description: Optional[str] = None
130
+ params: List[ParamSpec] = Field(default_factory=list)
131
+
132
+
133
+ class Entity(ManifestModel):
134
+ """A record type: its fields, relationships, and per-entity operations."""
135
+
136
+ name: str
137
+ fields: List[FieldSpec] = Field(default_factory=list)
138
+ relationships: List[Relationship] = Field(default_factory=list)
139
+ operations: List[Operation] = Field(default_factory=list)
140
+
141
+
142
+ class InterfaceManifest(ManifestModel):
143
+ """The full structural description of a server."""
144
+
145
+ service: str
146
+ manifest_version: str = MANIFEST_VERSION
147
+ entities: List[Entity] = Field(default_factory=list)
148
+ actions: List[Action] = Field(default_factory=list)
149
+
150
+ def to_json(self) -> str:
151
+ """Deterministic, human-diffable JSON (trailing newline for POSIX).
152
+
153
+ ``exclude_none`` drops absent optionals (``format``/``enum``/
154
+ ``description``) so committed goldens stay compact; Pydantic consumers
155
+ see absent == default anyway.
156
+ """
157
+ return self.model_dump_json(indent=2, exclude_none=True) + "\n"
158
+
159
+
160
+ # ── CLI interface projection ────────────────────────────────────────────────
161
+ #
162
+ # The command-oriented view of the structural core: per-entity commands
163
+ # (list/get/create/update/delete) + actions, each backed by one MCP tool with
164
+ # its required/optional params (enum fields carry their constrained options).
165
+
166
+
167
+ class CliCommand(ManifestModel):
168
+ """One CLI command: an entity operation or action, backed by one MCP tool."""
169
+
170
+ tool: str
171
+ summary: Optional[str] = None
172
+ description: Optional[str] = None
173
+ params: List[ParamSpec] = Field(default_factory=list)
174
+
175
+
176
+ class CliEntity(ManifestModel):
177
+ """An entity's CLI commands, keyed by verb (``list``/``get``/...)."""
178
+
179
+ entity: str
180
+ commands: Dict[str, CliCommand] = Field(default_factory=dict)
181
+
182
+
183
+ class CliAction(ManifestModel):
184
+ """A non-CRUD tool exposed as its own top-level CLI command."""
185
+
186
+ name: str
187
+ tool: str
188
+ params: List[ParamSpec] = Field(default_factory=list)
189
+ description: Optional[str] = None
190
+
191
+
192
+ class CliManifest(ManifestModel):
193
+ """The CLI projection of a server's structural core."""
194
+
195
+ service: str
196
+ manifest_version: str = MANIFEST_VERSION
197
+ interface: Literal["cli"] = "cli"
198
+ entities: List[CliEntity] = Field(default_factory=list)
199
+ actions: List[CliAction] = Field(default_factory=list)
200
+
201
+ def to_json(self) -> str:
202
+ """Deterministic, human-diffable JSON (see :meth:`InterfaceManifest.to_json`)."""
203
+ return self.model_dump_json(indent=2, exclude_none=True) + "\n"
@@ -0,0 +1,81 @@
1
+ """Pre-deploy fit-check for the data-plane intake declaration.
2
+
3
+ Pure functions over plain dicts — no I/O, no Mongo/S3 — so they run at authoring/
4
+ registration time. The agent-env-side ``preflight(universe, env)`` wraps these by
5
+ reading ``client.intake_declaration(card)`` and each service's seed ``data.json``.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Optional
10
+
11
+
12
+ def _json_add_format(declaration: dict, content_format: str) -> Optional[dict]:
13
+ """Return the declared ``data/add`` format for an inline (part=data) payload, or None."""
14
+ for fmt in declaration.get("add") or []:
15
+ if fmt.get("part") == "data" and fmt.get("format") == content_format:
16
+ return fmt
17
+ return None
18
+
19
+
20
+ def intake_fit_check(
21
+ declaration: Optional[dict],
22
+ data_json: dict,
23
+ *,
24
+ content_format: str = "json",
25
+ ) -> list[str]:
26
+ """Check a candidate seed ``data_json`` against a declared intake.
27
+
28
+ ``declaration`` is the dict from ``client.intake_declaration(card)`` (an
29
+ ``IntakeDeclaration``), or ``None``. ``data_json`` is the collection→rows seed the
30
+ server will load via ``load_from_json``.
31
+
32
+ Returns a list of human-readable issues; empty means it fits (or there is no claim
33
+ to check against). Absence of a declaration reads as "no claim" and returns ``[]``,
34
+ matching ``intake_declaration``'s absence semantics.
35
+
36
+ The check models the *loadable subset* and tolerates extra fields (real
37
+ ``data.json`` is a superset on validated-tier servers): it flags collections the
38
+ server does not accept and rows missing a declared required field, but never
39
+ complains about extra fields.
40
+ """
41
+ if not declaration:
42
+ return []
43
+
44
+ fmt = _json_add_format(declaration, content_format)
45
+ if fmt is None:
46
+ accepted = [
47
+ f"{f.get('part')}/{f.get('format')}" for f in declaration.get("add") or []
48
+ ]
49
+ return [
50
+ f"data/add does not accept part=data format={content_format} "
51
+ f"(accepted: {accepted or 'nothing'})"
52
+ ]
53
+
54
+ tables = fmt.get("tables")
55
+ if not tables:
56
+ # Accepted, but no per-table schema was declared — nothing more to verify.
57
+ return []
58
+
59
+ issues: list[str] = []
60
+ for collection, rows in data_json.items():
61
+ if not isinstance(rows, list):
62
+ # Scalar top-level extras (e.g. contacts.current_user_id, email.user_email)
63
+ # ride alongside the collections on export; they are not tables to validate.
64
+ continue
65
+ if collection not in tables:
66
+ issues.append(
67
+ f"collection '{collection}' is not accepted "
68
+ f"(declared: {sorted(tables)})"
69
+ )
70
+ continue
71
+ schema = tables[collection] or {}
72
+ required = schema.get("required") or []
73
+ if not required:
74
+ continue
75
+ for i, row in enumerate(rows):
76
+ if not isinstance(row, dict):
77
+ continue
78
+ missing = [r for r in required if r not in row]
79
+ if missing:
80
+ issues.append(f"{collection}[{i}] missing required field(s): {missing}")
81
+ return issues