@@ -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
17491954def _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+ }
0 commit comments