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.
@@ -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()