Skip to content

Commit 8a13e12

Browse files
authored
feat: add apply_scenario() and validate_scenario() to AxmeClient (#30)
* feat(sdk): add list_intent_events method and update README Made-with: Cursor * feat: add apply_scenario() and validate_scenario() to AxmeClient apply_scenario() submits a ScenarioBundle to POST /v1/scenarios/bundle in one atomic call — provisions agents, compiles the workflow, and creates the intent. validate_scenario() performs a dry-run via POST /v1/scenarios/validate without creating any resources. Made-with: Cursor * fix(sdk): update apply_scenario URL to /v1/scenarios/apply and fix docstring Made-with: Cursor * feat(sdk): add listen(address) method for agent intent stream delivery Implements GET /v1/agents/{address}/intents/stream SSE consumer in AxmeClient. Adds _iter_agent_intents_stream private helper, updates _is_terminal_intent_event to include TIMED_OUT status, and adds 11 comprehensive tests covering SSE parsing, scheme stripping, cursor advancement across reconnects, timeout, and auth errors. Made-with: Cursor
1 parent e39ced9 commit 8a13e12

4 files changed

Lines changed: 429 additions & 7 deletions

File tree

README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,19 +89,25 @@ client = AxmeClient(
8989
# Check connectivity
9090
print(client.health())
9191

92-
# Send an intent
92+
# Send an intent to a registered agent address
9393
intent = client.create_intent(
9494
{
9595
"intent_type": "order.fulfillment.v1",
96+
"to_agent": "agent://acme-corp/production/fulfillment-service",
9697
"payload": {"order_id": "ord_123", "priority": "high"},
97-
"owner_agent": "agent://fulfillment-service",
9898
},
9999
idempotency_key="fulfill-ord-123-001",
100+
correlation_id="corr-ord-123-001",
100101
)
101102
print(intent["intent_id"], intent["status"])
102103

104+
# List registered agent addresses in your workspace
105+
agents = client.list_agents(org_id="acme-corp-uuid", workspace_id="prod-ws-uuid")
106+
for agent in agents["agents"]:
107+
print(agent["address"], agent["status"])
108+
103109
# Wait for resolution
104-
resolved = client.wait_for(intent["intent_id"], terminal_states={"RESOLVED", "CANCELLED"})
110+
resolved = client.wait_for(intent["intent_id"])
105111
print(resolved["status"])
106112
```
107113

axme_sdk/client.py

Lines changed: 209 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,49 @@ def send_intent(
115115
raise ValueError("create_intent response does not include string intent_id")
116116
return intent_id
117117

118+
def apply_scenario(
119+
self,
120+
bundle: dict[str, Any],
121+
*,
122+
idempotency_key: str | None = None,
123+
trace_id: str | None = None,
124+
) -> dict[str, Any]:
125+
"""Submit a ScenarioBundle to POST /v1/scenarios/apply.
126+
127+
The server provisions missing agents, compiles the workflow, and creates the
128+
intent in one atomic operation. Returns the full bundle response including
129+
``intent_id``, ``compile_id``, ``agents_provisioned``.
130+
"""
131+
payload = dict(bundle)
132+
if idempotency_key is not None:
133+
payload.setdefault("idempotency_key", idempotency_key)
134+
return self._request_json(
135+
"POST",
136+
"/v1/scenarios/apply",
137+
json_body=payload,
138+
idempotency_key=idempotency_key,
139+
trace_id=trace_id,
140+
retryable=idempotency_key is not None,
141+
)
142+
143+
def validate_scenario(
144+
self,
145+
bundle: dict[str, Any],
146+
*,
147+
trace_id: str | None = None,
148+
) -> dict[str, Any]:
149+
"""Dry-run validate a ScenarioBundle without creating any resources.
150+
151+
Returns a list of validation errors (empty list means valid).
152+
"""
153+
return self._request_json(
154+
"POST",
155+
"/v1/scenarios/validate",
156+
json_body=bundle,
157+
trace_id=trace_id,
158+
retryable=True,
159+
)
160+
118161
def list_intent_events(
119162
self,
120163
intent_id: str,
@@ -744,6 +787,121 @@ def create_service_account_key(
744787
retryable=idempotency_key is not None,
745788
)
746789

790+
def list_agents(
791+
self,
792+
*,
793+
org_id: str,
794+
workspace_id: str,
795+
limit: int | None = None,
796+
trace_id: str | None = None,
797+
) -> dict[str, Any]:
798+
"""List registered agent addresses in a workspace.
799+
800+
Returns a dict with an ``agents`` list, each entry containing
801+
``address``, ``display_name``, ``status``, and ``created_at``.
802+
"""
803+
params: dict[str, str] = {"org_id": org_id, "workspace_id": workspace_id}
804+
if limit is not None:
805+
params["limit"] = str(limit)
806+
return self._request_json(
807+
"GET",
808+
"/v1/agents",
809+
params=params,
810+
trace_id=trace_id,
811+
retryable=True,
812+
)
813+
814+
def get_agent(self, address: str, *, trace_id: str | None = None) -> dict[str, Any]:
815+
"""Get agent address details by full ``agent://org/workspace/name`` address."""
816+
if not isinstance(address, str) or not address.strip():
817+
raise ValueError("address must be a non-empty string")
818+
path_part = address.strip()
819+
if path_part.startswith("agent://"):
820+
path_part = path_part[len("agent://"):]
821+
return self._request_json(
822+
"GET",
823+
f"/v1/agents/{path_part}",
824+
trace_id=trace_id,
825+
retryable=True,
826+
)
827+
828+
def listen(
829+
self,
830+
address: str,
831+
*,
832+
since: int = 0,
833+
wait_seconds: int = 15,
834+
timeout_seconds: float | None = None,
835+
trace_id: str | None = None,
836+
) -> Iterator[dict[str, Any]]:
837+
"""Stream incoming intents for an agent address via SSE.
838+
839+
Connects to ``GET /v1/agents/{address}/intents/stream`` and yields each
840+
intent payload as it arrives. The stream is a long-lived SSE connection;
841+
the server sends a ``stream.timeout`` keepalive event when there are no
842+
new intents within ``wait_seconds``, at which point the client
843+
automatically reconnects until ``timeout_seconds`` elapses (or forever if
844+
``timeout_seconds`` is ``None``).
845+
846+
Args:
847+
address: Full ``agent://org/workspace/name`` or bare ``org/workspace/name``
848+
agent address to listen on.
849+
since: Sequence cursor — only intents with a sequence number greater
850+
than this value are returned. Pass the ``seq`` value from the last
851+
received event to resume without gaps.
852+
wait_seconds: Server-side long-poll window (1–60 s). The server keeps
853+
the connection open for up to this many seconds while waiting for
854+
new intents.
855+
timeout_seconds: Optional wall-clock timeout after which the method
856+
raises ``TimeoutError``. ``None`` means listen indefinitely.
857+
trace_id: Optional trace ID forwarded as ``X-Trace-Id``.
858+
859+
Yields:
860+
Each intent payload dict as it arrives on the stream.
861+
862+
Raises:
863+
ValueError: If ``address`` is empty or arguments are out of range.
864+
TimeoutError: If ``timeout_seconds`` elapses before the caller
865+
stops iterating.
866+
"""
867+
if not isinstance(address, str) or not address.strip():
868+
raise ValueError("address must be a non-empty string")
869+
if since < 0:
870+
raise ValueError("since must be >= 0")
871+
if wait_seconds < 1:
872+
raise ValueError("wait_seconds must be >= 1")
873+
if timeout_seconds is not None and timeout_seconds <= 0:
874+
raise ValueError("timeout_seconds must be > 0 when provided")
875+
876+
path_part = address.strip()
877+
if path_part.startswith("agent://"):
878+
path_part = path_part[len("agent://"):]
879+
880+
deadline = (time.monotonic() + timeout_seconds) if timeout_seconds is not None else None
881+
next_since = since
882+
883+
while True:
884+
if deadline is not None and time.monotonic() >= deadline:
885+
raise TimeoutError(f"timed out while listening on {address}")
886+
887+
stream_wait_seconds = wait_seconds
888+
if deadline is not None:
889+
seconds_left = max(0.0, deadline - time.monotonic())
890+
if seconds_left <= 0:
891+
raise TimeoutError(f"timed out while listening on {address}")
892+
stream_wait_seconds = max(1, min(wait_seconds, int(seconds_left)))
893+
894+
for event in self._iter_agent_intents_stream(
895+
path_part=path_part,
896+
since=next_since,
897+
wait_seconds=stream_wait_seconds,
898+
trace_id=trace_id,
899+
):
900+
seq = event.get("seq")
901+
if isinstance(seq, int) and seq >= 0:
902+
next_since = max(next_since, seq)
903+
yield event
904+
747905
def revoke_service_account_key(
748906
self,
749907
service_account_id: str,
@@ -1546,6 +1704,53 @@ def _iter_intent_events_stream(
15461704
data_lines.append(line.partition(":")[2].lstrip())
15471705
continue
15481706

1707+
def _iter_agent_intents_stream(
1708+
self,
1709+
*,
1710+
path_part: str,
1711+
since: int,
1712+
wait_seconds: int,
1713+
trace_id: str | None,
1714+
) -> Iterator[dict[str, Any]]:
1715+
headers: dict[str, str] | None = None
1716+
normalized_trace_id = self._normalize_trace_id(trace_id)
1717+
if normalized_trace_id is not None:
1718+
headers = {"X-Trace-Id": normalized_trace_id}
1719+
1720+
with self._http.stream(
1721+
"GET",
1722+
f"/v1/agents/{path_part}/intents/stream",
1723+
params={"since": str(since), "wait_seconds": str(wait_seconds)},
1724+
headers=headers,
1725+
) as response:
1726+
if response.status_code >= 400:
1727+
self._raise_http_error(response)
1728+
1729+
current_event: str | None = None
1730+
data_lines: list[str] = []
1731+
for line in response.iter_lines():
1732+
if line == "":
1733+
if current_event == "stream.timeout":
1734+
return
1735+
if current_event and data_lines:
1736+
try:
1737+
payload = json.loads("\n".join(data_lines))
1738+
except ValueError:
1739+
payload = None
1740+
if isinstance(payload, dict) and current_event.startswith("intent."):
1741+
yield payload
1742+
current_event = None
1743+
data_lines = []
1744+
continue
1745+
if line.startswith(":"):
1746+
continue
1747+
if line.startswith("event:"):
1748+
current_event = line.partition(":")[2].strip()
1749+
continue
1750+
if line.startswith("data:"):
1751+
data_lines.append(line.partition(":")[2].lstrip())
1752+
continue
1753+
15491754
def _mcp_request(
15501755
self,
15511756
*,
@@ -1748,7 +1953,9 @@ def _max_seen_seq(*, next_since: int, event: dict[str, Any]) -> int:
17481953

17491954
def _is_terminal_intent_event(event: dict[str, Any]) -> bool:
17501955
status = event.get("status")
1751-
if isinstance(status, str) and status in {"COMPLETED", "FAILED", "CANCELED"}:
1956+
if isinstance(status, str) and status in {"COMPLETED", "FAILED", "CANCELED", "TIMED_OUT"}:
17521957
return True
17531958
event_type = event.get("event_type")
1754-
return isinstance(event_type, str) and event_type in {"intent.completed", "intent.failed", "intent.canceled"}
1959+
return isinstance(event_type, str) and event_type in {
1960+
"intent.completed", "intent.failed", "intent.canceled", "intent.timed_out"
1961+
}

examples/basic_submit.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,7 @@ def main() -> None:
1515
created = client.create_intent(
1616
{
1717
"intent_type": "intent.demo.v1",
18-
"from_agent": "agent://basic/python/source",
19-
"to_agent": "agent://basic/python/target",
18+
"to_agent": "agent://acme-corp/production/target",
2019
"payload": {"task": "hello-from-python"},
2120
},
2221
correlation_id=correlation_id,

0 commit comments

Comments
 (0)