iagent-mesh 0.4.0__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.
- iagent_mesh/__init__.py +27 -0
- iagent_mesh/client.py +189 -0
- iagent_mesh/config.py +62 -0
- iagent_mesh/core.py +618 -0
- iagent_mesh/identity_stanzas.py +77 -0
- iagent_mesh/models.py +16 -0
- iagent_mesh/registration_transport.py +145 -0
- iagent_mesh/scaffold_core.py +150 -0
- iagent_mesh/service_identity.py +95 -0
- iagent_mesh/shapes.py +163 -0
- iagent_mesh/templates/01_pure_math/app.py +42 -0
- iagent_mesh/templates/01_pure_math/pyproject.toml +9 -0
- iagent_mesh/templates/01_pure_math/template.yaml +2 -0
- iagent_mesh/templates/02_instructor_polars/app.py +61 -0
- iagent_mesh/templates/02_instructor_polars/prompts/instructions.yaml +2 -0
- iagent_mesh/templates/02_instructor_polars/pyproject.toml +13 -0
- iagent_mesh/templates/02_instructor_polars/template.yaml +2 -0
- iagent_mesh/templates/03_baml_pandas/app.py +49 -0
- iagent_mesh/templates/03_baml_pandas/baml_src/schema.baml +11 -0
- iagent_mesh/templates/03_baml_pandas/pyproject.toml +12 -0
- iagent_mesh/templates/03_baml_pandas/template.yaml +2 -0
- iagent_mesh/templates/legacy_adapter/app.py +70 -0
- iagent_mesh/templates/legacy_adapter/pyproject.toml +15 -0
- iagent_mesh/templates/smolagents_subswarm/app.py +60 -0
- iagent_mesh/templates/smolagents_subswarm/pyproject.toml +14 -0
- iagent_mesh/transport_auth.py +492 -0
- iagent_mesh-0.4.0.dist-info/METADATA +84 -0
- iagent_mesh-0.4.0.dist-info/RECORD +30 -0
- iagent_mesh-0.4.0.dist-info/WHEEL +4 -0
- iagent_mesh-0.4.0.dist-info/licenses/LICENSE +21 -0
iagent_mesh/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# iagent_mesh namespace
|
|
2
|
+
from .client import MeshClient, MeshResponse
|
|
3
|
+
from .shapes import (
|
|
4
|
+
ARCHETYPE_BAML_NAME,
|
|
5
|
+
VERB_OUTPUT_URI,
|
|
6
|
+
Archetypes,
|
|
7
|
+
InputShapes,
|
|
8
|
+
OutputShapes,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
# Identity is part of the SDK's public surface, not an internal of transport_auth: a tool
|
|
12
|
+
# handler annotates a parameter `CallerIdentity` and a helper below it calls `current_caller()`.
|
|
13
|
+
# Importing those from a module named `transport_auth` misfiles them as a transport concern —
|
|
14
|
+
# they are the answer to "who is asking", which is the whole per-user read path.
|
|
15
|
+
from .transport_auth import CallerIdentity, current_caller
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"MeshClient",
|
|
19
|
+
"MeshResponse",
|
|
20
|
+
"CallerIdentity",
|
|
21
|
+
"current_caller",
|
|
22
|
+
"OutputShapes",
|
|
23
|
+
"InputShapes",
|
|
24
|
+
"Archetypes",
|
|
25
|
+
"ARCHETYPE_BAML_NAME",
|
|
26
|
+
"VERB_OUTPUT_URI",
|
|
27
|
+
]
|
iagent_mesh/client.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Client for the mesh's control plane (`POST /orchestrate` on cortex-bff)."""
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from typing import Any, Dict, Iterator, List, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .service_identity import ServiceTokenError, mint_mesh_token # noqa: F401
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("iagent_mesh.client")
|
|
12
|
+
|
|
13
|
+
#: cortex-bff's in-cluster address. The service listens on 8090; this default said 8000 for
|
|
14
|
+
#: four months, so the zero-config constructor pointed at a port nothing serves.
|
|
15
|
+
DEFAULT_GATEWAY_URL = "http://iagent-cortex-bff:8090/orchestrate"
|
|
16
|
+
|
|
17
|
+
#: The answer event. `generate_dagster_stream` emits `final_payload` carrying the
|
|
18
|
+
#: Server-Driven-UI component JSON; the other three are older names kept for back-compat with
|
|
19
|
+
#: deployments that have not rolled forward.
|
|
20
|
+
_ANSWER_EVENTS = ("final_payload", "final_response", "complete", "result")
|
|
21
|
+
|
|
22
|
+
#: Emitted when the pipeline fails mid-stream. It arrives on a 200 response — the status line is
|
|
23
|
+
#: sent before the failure exists — so a client that only checks HTTP status reports success for
|
|
24
|
+
#: a run that produced no answer.
|
|
25
|
+
_ERROR_EVENTS = ("pipeline_error", "access_denied")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MeshResponse:
|
|
29
|
+
"""The result of one `ask`, with the raw event stream retained.
|
|
30
|
+
|
|
31
|
+
`text` is the answer. `events` is every SSE frame in order — the status/routing trace the UI
|
|
32
|
+
renders — kept because a caller debugging a bad answer needs to see WHICH engine answered,
|
|
33
|
+
and that is only in the trace.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
__slots__ = ("text", "payload", "events")
|
|
37
|
+
|
|
38
|
+
def __init__(self, text: str, payload: Optional[Dict[str, Any]], events: List[Dict[str, Any]]):
|
|
39
|
+
self.text = text
|
|
40
|
+
self.payload = payload
|
|
41
|
+
self.events = events
|
|
42
|
+
|
|
43
|
+
def __str__(self) -> str:
|
|
44
|
+
return self.text
|
|
45
|
+
|
|
46
|
+
def __repr__(self) -> str: # pragma: no cover — debugging aid
|
|
47
|
+
return f"<MeshResponse {len(self.text)} chars, {len(self.events)} events>"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class MeshClient:
|
|
51
|
+
"""Client for calling the mesh gateway under a SERVICE IDENTITY.
|
|
52
|
+
|
|
53
|
+
WHAT CHANGED AND WHY (2026-08-07). This class used to read a single static
|
|
54
|
+
`MESH_DEV_TOKEN` from the environment and raise without it, with the message *"Ensure
|
|
55
|
+
you are running within the secured JupyterHub environment."* That sentence is the
|
|
56
|
+
architecture's old trust model preserved in prose: a long-lived credential whose safety
|
|
57
|
+
rests on WHERE THE PROCESS HAPPENS TO RUN — security assumed at a boundary the component
|
|
58
|
+
does not control. CLOSED HERE in 0.2.0 by mint-at-use. The same shape is STILL OPEN one hop
|
|
59
|
+
away in the DA read path, which defers to a gateway it cannot verify — tracked as
|
|
60
|
+
``[[da-sends-no-user-token]]``.
|
|
61
|
+
|
|
62
|
+
Minting does not modernise the token; it REMOVES THE PERIMETER DEPENDENCY. Afterwards
|
|
63
|
+
the SDK's outbound trust rests on an identity the platform declares and reconciles, and
|
|
64
|
+
the caller is authenticated wherever it runs.
|
|
65
|
+
|
|
66
|
+
`MESH_DEV_TOKEN` survives as a DEV fallback and ANNOUNCES ITSELF, so a static token in a
|
|
67
|
+
real deployment reads as the anomaly it is instead of passing silently.
|
|
68
|
+
|
|
69
|
+
THE WIRE CONTRACT WAS THREE-WAYS WRONG (fixed 2026-08-27). `ask` posted `{"prompt": ...}`
|
|
70
|
+
and called `response.json()` against a default URL on port 8000. The gateway's
|
|
71
|
+
`InterviewRequest` requires `message` AND `session_id` (a missing `session_id` used to be
|
|
72
|
+
filled with a fresh UUID per request, which defeated the run-tracker's dedup and fired
|
|
73
|
+
duplicate Dagster runs — so it was made required); the handler returns
|
|
74
|
+
`StreamingResponse(media_type="text/event-stream")`; and the service listens on 8090. Every
|
|
75
|
+
call therefore 422'd, and had it not, `.json()` would have failed on an SSE body. This was
|
|
76
|
+
never caught because the test suite asserted the SDK sent `{"prompt": ...}` — pinning the
|
|
77
|
+
defect rather than the contract, against a mock that could not disagree.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, gateway_url: str = DEFAULT_GATEWAY_URL, *, session_id: Optional[str] = None):
|
|
81
|
+
self.gateway_url = gateway_url
|
|
82
|
+
self._static_token = os.getenv("MESH_DEV_TOKEN")
|
|
83
|
+
# One client, one conversation by default. The gateway keys its run-tracker on
|
|
84
|
+
# session_id, so reusing it across calls is what makes a follow-up question a follow-up
|
|
85
|
+
# rather than a new thread; callers wanting isolation pass their own per call.
|
|
86
|
+
self._session_id = session_id or f"sdk-{os.getpid()}"
|
|
87
|
+
|
|
88
|
+
def _authorization(self) -> str:
|
|
89
|
+
if self._static_token:
|
|
90
|
+
logger.warning("outbound identity: MESH_DEV_TOKEN (static, dev fallback) — "
|
|
91
|
+
"no service identity in use")
|
|
92
|
+
return f"Bearer {self._static_token}"
|
|
93
|
+
token = mint_mesh_token()
|
|
94
|
+
logger.info("outbound identity: %s (minted)",
|
|
95
|
+
os.getenv("MESH_CLIENT_ID", "service-identity"))
|
|
96
|
+
return f"Bearer {token}"
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
def _iter_events(lines: Iterator[str]) -> Iterator[Dict[str, Any]]:
|
|
100
|
+
"""Parse an SSE byte-stream into `{"event": name, "data": parsed}` frames.
|
|
101
|
+
|
|
102
|
+
Non-JSON `data:` is surfaced as `{"raw": ...}` rather than dropped — a frame the server
|
|
103
|
+
emits and the client silently discards is how a stream "returns nothing" with no error.
|
|
104
|
+
"""
|
|
105
|
+
current: Optional[str] = None
|
|
106
|
+
for line in lines:
|
|
107
|
+
if line.startswith("event:"):
|
|
108
|
+
current = line[len("event:"):].strip()
|
|
109
|
+
elif line.startswith("data:"):
|
|
110
|
+
raw = line[len("data:"):].strip()
|
|
111
|
+
try:
|
|
112
|
+
data = json.loads(raw)
|
|
113
|
+
except ValueError:
|
|
114
|
+
data = {"raw": raw}
|
|
115
|
+
yield {"event": current, "data": data}
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def _text_from(payload: Any) -> str:
|
|
119
|
+
"""Pull the human-readable answer out of a Server-Driven-UI payload."""
|
|
120
|
+
if isinstance(payload, str):
|
|
121
|
+
return payload
|
|
122
|
+
if not isinstance(payload, dict):
|
|
123
|
+
return ""
|
|
124
|
+
parts: List[str] = []
|
|
125
|
+
for comp in (payload.get("components") or []):
|
|
126
|
+
if isinstance(comp, dict):
|
|
127
|
+
md = comp.get("markdown_content") or comp.get("content")
|
|
128
|
+
if md:
|
|
129
|
+
parts.append(str(md))
|
|
130
|
+
if parts:
|
|
131
|
+
return "\n".join(parts)
|
|
132
|
+
for key in ("answer", "text", "message", "content"):
|
|
133
|
+
if payload.get(key):
|
|
134
|
+
return str(payload[key])
|
|
135
|
+
return ""
|
|
136
|
+
|
|
137
|
+
def ask(self, prompt: str, *, session_id: Optional[str] = None,
|
|
138
|
+
timeout: float = 300.0) -> MeshResponse:
|
|
139
|
+
"""Ask the orchestrator a question and return its answer.
|
|
140
|
+
|
|
141
|
+
Blocks until the stream completes. The default timeout is 300s, not 30s: a multi-hop
|
|
142
|
+
supervisor query routinely runs minutes, and the old 30s ceiling would have reported a
|
|
143
|
+
timeout for a query that was progressing normally.
|
|
144
|
+
"""
|
|
145
|
+
headers = {
|
|
146
|
+
"Authorization": self._authorization(),
|
|
147
|
+
"Content-Type": "application/json",
|
|
148
|
+
"Accept": "text/event-stream",
|
|
149
|
+
}
|
|
150
|
+
body = {"message": prompt, "session_id": session_id or self._session_id}
|
|
151
|
+
|
|
152
|
+
events: List[Dict[str, Any]] = []
|
|
153
|
+
answer: Optional[Dict[str, Any]] = None
|
|
154
|
+
streamed = ""
|
|
155
|
+
|
|
156
|
+
with httpx.Client(timeout=httpx.Timeout(timeout, connect=10.0)) as client:
|
|
157
|
+
with client.stream("POST", self.gateway_url, headers=headers, json=body) as response:
|
|
158
|
+
if response.status_code != 200:
|
|
159
|
+
# Read the body before raising: the gateway puts the REASON here (422's
|
|
160
|
+
# field errors, 403 `cell_not_entitled` with the entitled cells). Letting
|
|
161
|
+
# `raise_for_status` fire on an unread streaming response discards exactly
|
|
162
|
+
# the text that says what to fix.
|
|
163
|
+
detail = response.read().decode("utf-8", "replace")[:500]
|
|
164
|
+
raise httpx.HTTPStatusError(
|
|
165
|
+
f"orchestrate failed: HTTP {response.status_code}: {detail}",
|
|
166
|
+
request=response.request, response=response,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
for frame in self._iter_events(response.iter_lines()):
|
|
170
|
+
events.append(frame)
|
|
171
|
+
name, data = frame["event"], frame["data"]
|
|
172
|
+
if name in _ERROR_EVENTS:
|
|
173
|
+
raise RuntimeError(f"mesh pipeline failed ({name}): {data}")
|
|
174
|
+
if name in _ANSWER_EVENTS:
|
|
175
|
+
answer = data
|
|
176
|
+
elif name in ("text", "delta"):
|
|
177
|
+
streamed += (data.get("content", "") if isinstance(data, dict)
|
|
178
|
+
else str(data))
|
|
179
|
+
|
|
180
|
+
text = self._text_from(answer) if answer is not None else streamed
|
|
181
|
+
if not text and answer is None:
|
|
182
|
+
# A 200 that carried no answer event is a FAILED run, not an empty one. Returning ""
|
|
183
|
+
# here would let a broken pipeline read as a terse reply.
|
|
184
|
+
raise RuntimeError(
|
|
185
|
+
"mesh returned no answer event "
|
|
186
|
+
f"(received {len(events)} events: {sorted({e['event'] for e in events if e['event']})}). "
|
|
187
|
+
"The run ended without emitting final_payload."
|
|
188
|
+
)
|
|
189
|
+
return MeshResponse(text, answer, events)
|
iagent_mesh/config.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""SDK environment-driven configuration.
|
|
2
|
+
|
|
3
|
+
Centralizes the URLs and tokens the SDK needs at runtime so engine
|
|
4
|
+
deployments can wire them via ConfigMap / Secret without code changes.
|
|
5
|
+
|
|
6
|
+
NOTHING HERE IS REQUIRED AT IMPORT TIME, and that is a correctness property rather than a
|
|
7
|
+
convenience. `Settings` is instantiated at module scope and `core.py` imports it, so a REQUIRED
|
|
8
|
+
field made `import iagent_mesh.core` — the first line of the quickstart, and of every scaffolded
|
|
9
|
+
tool — raise `ValidationError` on any machine that had not already exported three variables:
|
|
10
|
+
|
|
11
|
+
pydantic_core._pydantic_core.ValidationError: 3 validation errors for Settings
|
|
12
|
+
GIT_PROVISION_API_URL / GIT_SERVER_HOST / ARTIFACTORY_BASE_URL: Field required
|
|
13
|
+
|
|
14
|
+
Those three are consumed ONLY by `scaffold_core` / `mcp_server` (repository provisioning). A data
|
|
15
|
+
scientist importing `MeshTool` to serve a tool, or to read data, needs none of them — yet could
|
|
16
|
+
not import the package at all without inventing values for a git-provisioning API they will never
|
|
17
|
+
call. The suite could not see this: `tests/conftest.py` sets all three before any import, so the
|
|
18
|
+
one environment guaranteed to have them was the one asserting the package worked.
|
|
19
|
+
|
|
20
|
+
The requirement is now enforced WHERE IT IS REAL — `require()` raises at the point of use, naming
|
|
21
|
+
the variable and what needs it — instead of at import, where it blocked every unrelated use.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
from pydantic_settings import BaseSettings
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Settings(BaseSettings):
|
|
30
|
+
# DataHub (predicate-graph registration inbox per ADR-0006).
|
|
31
|
+
# Optional because the SDK is usable for local-dev without registering;
|
|
32
|
+
# ``MESH_REGISTER_ON_STARTUP`` gates whether registration actually fires.
|
|
33
|
+
DATAHUB_GMS_URL: Optional[str] = None
|
|
34
|
+
DATAHUB_TOKEN: Optional[str] = None
|
|
35
|
+
|
|
36
|
+
# Provisioning + git platform integration. Used by scaffold_core / mcp_server, NEVER by
|
|
37
|
+
# MeshTool — hence optional here and demanded by `require()` at the call site.
|
|
38
|
+
GIT_PROVISION_API_URL: Optional[str] = None
|
|
39
|
+
GIT_SERVER_HOST: Optional[str] = None
|
|
40
|
+
ARTIFACTORY_BASE_URL: Optional[str] = None
|
|
41
|
+
PLATFORM_GIT_TOKEN: Optional[str] = None
|
|
42
|
+
MESH_DEV_TOKEN: Optional[str] = None
|
|
43
|
+
|
|
44
|
+
def require(self, name: str) -> str:
|
|
45
|
+
"""Return a setting that the CALLER genuinely cannot proceed without, or fail naming it.
|
|
46
|
+
|
|
47
|
+
Deferring the check from import to use does not weaken it: an unset value still stops the
|
|
48
|
+
operation that needs it, and now says which operation that was. `MeshTool` never calls
|
|
49
|
+
this, which is the point — the scaffolding paths keep their hard requirement while the
|
|
50
|
+
serving path stops inheriting it.
|
|
51
|
+
"""
|
|
52
|
+
value = getattr(self, name, None)
|
|
53
|
+
if not value:
|
|
54
|
+
raise RuntimeError(
|
|
55
|
+
f"{name} is not set. It is required for repository provisioning / scaffolding "
|
|
56
|
+
f"(scaffold_core, mcp_server). Serving a MeshTool does not need it — see "
|
|
57
|
+
f".env.example."
|
|
58
|
+
)
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
settings = Settings()
|