yaab-sdk 0.1.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.
Files changed (119) hide show
  1. yaab/__init__.py +159 -0
  2. yaab/_core.py +214 -0
  3. yaab/a2a/__init__.py +14 -0
  4. yaab/a2a/client.py +170 -0
  5. yaab/agent.py +330 -0
  6. yaab/agui.py +205 -0
  7. yaab/artifacts/__init__.py +54 -0
  8. yaab/artifacts/manager.py +86 -0
  9. yaab/auth.py +127 -0
  10. yaab/batch.py +115 -0
  11. yaab/cli.py +469 -0
  12. yaab/config.py +457 -0
  13. yaab/content.py +143 -0
  14. yaab/context.py +119 -0
  15. yaab/deploy.py +411 -0
  16. yaab/eval/__init__.py +128 -0
  17. yaab/eval/adapters/__init__.py +4 -0
  18. yaab/eval/adapters/deepeval.py +74 -0
  19. yaab/eval/adapters/ragas.py +76 -0
  20. yaab/exceptions.py +98 -0
  21. yaab/extensions.py +127 -0
  22. yaab/governance/__init__.py +147 -0
  23. yaab/governance/approval.py +89 -0
  24. yaab/governance/audit.py +155 -0
  25. yaab/governance/authorization.py +223 -0
  26. yaab/governance/compliance/__init__.py +59 -0
  27. yaab/governance/compliance/base.py +88 -0
  28. yaab/governance/compliance/eu_ai_act.py +83 -0
  29. yaab/governance/compliance/iso_42001.py +49 -0
  30. yaab/governance/compliance/nist_ai_rmf.py +62 -0
  31. yaab/governance/compliance/soc2.py +47 -0
  32. yaab/governance/compliance/sr_11_7.py +90 -0
  33. yaab/governance/eval.py +391 -0
  34. yaab/governance/evalset.py +159 -0
  35. yaab/governance/guardrails/__init__.py +48 -0
  36. yaab/governance/guardrails/llm_guard.py +82 -0
  37. yaab/governance/guardrails/nemo.py +74 -0
  38. yaab/governance/guardrails/presidio.py +72 -0
  39. yaab/governance/lifecycle.py +142 -0
  40. yaab/governance/monitor.py +138 -0
  41. yaab/governance/policy.py +211 -0
  42. yaab/governance/registry.py +286 -0
  43. yaab/governance/service.py +118 -0
  44. yaab/governance/simulation.py +307 -0
  45. yaab/graph/__init__.py +53 -0
  46. yaab/graph/checkpoint.py +191 -0
  47. yaab/graph/state.py +431 -0
  48. yaab/limits.py +144 -0
  49. yaab/memory/__init__.py +155 -0
  50. yaab/memory/embedders.py +67 -0
  51. yaab/memory/extraction.py +119 -0
  52. yaab/memory/manager.py +194 -0
  53. yaab/models/__init__.py +33 -0
  54. yaab/models/base.py +75 -0
  55. yaab/models/instrumented.py +64 -0
  56. yaab/models/litellm_provider.py +341 -0
  57. yaab/models/resilient.py +139 -0
  58. yaab/models/router.py +194 -0
  59. yaab/models/test_model.py +154 -0
  60. yaab/multiagent.py +291 -0
  61. yaab/observability/__init__.py +113 -0
  62. yaab/observability/sinks.py +109 -0
  63. yaab/optimize/__init__.py +30 -0
  64. yaab/optimize/module.py +132 -0
  65. yaab/optimize/optimizer.py +296 -0
  66. yaab/optimize/signature.py +70 -0
  67. yaab/plugins/__init__.py +71 -0
  68. yaab/plugins/builtins.py +102 -0
  69. yaab/prompts.py +98 -0
  70. yaab/py.typed +0 -0
  71. yaab/rag/__init__.py +75 -0
  72. yaab/rag/chunking.py +116 -0
  73. yaab/rag/eval.py +84 -0
  74. yaab/rag/knowledge.py +168 -0
  75. yaab/rag/loaders.py +171 -0
  76. yaab/rag/memory_service.py +92 -0
  77. yaab/rag/rerank.py +133 -0
  78. yaab/rag/store.py +226 -0
  79. yaab/rag/stores_external.py +645 -0
  80. yaab/rag/types.py +62 -0
  81. yaab/runner.py +986 -0
  82. yaab/serve.py +372 -0
  83. yaab/sessions/__init__.py +56 -0
  84. yaab/sessions/base.py +38 -0
  85. yaab/sessions/manager.py +136 -0
  86. yaab/sessions/memory.py +33 -0
  87. yaab/sessions/postgres.py +80 -0
  88. yaab/sessions/redis.py +79 -0
  89. yaab/sessions/sqlite.py +58 -0
  90. yaab/skills.py +84 -0
  91. yaab/state.py +122 -0
  92. yaab/streaming.py +172 -0
  93. yaab/testing/__init__.py +8 -0
  94. yaab/tools/__init__.py +25 -0
  95. yaab/tools/agent_tool.py +52 -0
  96. yaab/tools/auth.py +212 -0
  97. yaab/tools/base.py +186 -0
  98. yaab/tools/builtin/__init__.py +80 -0
  99. yaab/tools/builtin/calculator.py +48 -0
  100. yaab/tools/builtin/code.py +38 -0
  101. yaab/tools/builtin/datetime_tool.py +19 -0
  102. yaab/tools/builtin/files.py +122 -0
  103. yaab/tools/builtin/grounding.py +59 -0
  104. yaab/tools/builtin/http.py +30 -0
  105. yaab/tools/builtin/search.py +123 -0
  106. yaab/tools/builtin/url_context.py +82 -0
  107. yaab/tools/mcp.py +62 -0
  108. yaab/tools/mcp_client.py +225 -0
  109. yaab/tools/mcp_server.py +252 -0
  110. yaab/tools/openapi.py +336 -0
  111. yaab/tools/sandbox.py +124 -0
  112. yaab/types.py +167 -0
  113. yaab/voice.py +383 -0
  114. yaab/web.py +383 -0
  115. yaab_sdk-0.1.0.dist-info/METADATA +533 -0
  116. yaab_sdk-0.1.0.dist-info/RECORD +119 -0
  117. yaab_sdk-0.1.0.dist-info/WHEEL +4 -0
  118. yaab_sdk-0.1.0.dist-info/entry_points.txt +9 -0
  119. yaab_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
yaab/__init__.py ADDED
@@ -0,0 +1,159 @@
1
+ """YAAB — Yet Another Agent Builder.
2
+
3
+ A type-safe, governance-first agent SDK with a Rust performance core. Type-safe,
4
+ optimizable, durable, and simple — the best ideas from across the agent
5
+ ecosystem on one runtime, on a universal LiteLLM model layer.
6
+
7
+ Quickstart::
8
+
9
+ from yaab import Agent
10
+
11
+ agent = Agent("assistant", model="openai/gpt-4o", instructions="Be concise.")
12
+ print(agent.run_sync("Say hello").output)
13
+
14
+ Offline (no API key)::
15
+
16
+ from yaab import Agent
17
+ from yaab.testing import TestModel
18
+
19
+ agent = Agent("assistant", model=TestModel("hi!"))
20
+ assert agent.run_sync("hello").output == "hi!"
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from . import _core
26
+ from .agent import Agent
27
+ from .artifacts.manager import ArtifactManager
28
+ from .batch import batch_embed, batch_map, batch_run
29
+ from .config import agent_from_dict, agent_from_yaml, runner_from_dict
30
+ from .content import Content, Part, PartKind
31
+ from .context import KeepAll, SummarizeHistory, TruncateMessages
32
+ from .eval import available_metrics, get_metric, register_metric
33
+ from .exceptions import (
34
+ ApprovalRequired,
35
+ GovernanceError,
36
+ MaxStepsExceeded,
37
+ ModelError,
38
+ OutputValidationError,
39
+ PolicyViolation,
40
+ RunCancelled,
41
+ ToolError,
42
+ UsageLimitExceeded,
43
+ YaabError,
44
+ )
45
+ from .extensions import available as available_components
46
+ from .extensions import get as get_component
47
+ from .extensions import register as register_component
48
+ from .governance.eval import ToolTrajectoryMatch
49
+ from .governance.evalset import EvalCase, EvalSet
50
+ from .graph.state import RetryPolicy
51
+ from .limits import CancellationToken, UsageLimits
52
+ from .memory.extraction import MemoryExtractor
53
+ from .memory.manager import MemoryManager
54
+ from .models.router import ModelRouter
55
+ from .multiagent import LoopAgent, MapAgent, ParallelAgent, SequentialAgent, Swarm
56
+ from .prompts import PromptRegistry
57
+ from .rag import Document, KnowledgeBase
58
+ from .rag.memory_service import KnowledgeBaseMemory
59
+ from .runner import Runner
60
+ from .sessions.manager import SessionManager
61
+ from .skills import Skill
62
+ from .state import State
63
+ from .tools import AgentTool, FunctionTool, tool
64
+ from .tools.auth import ToolAuth, ToolAuthRequired, ToolCredential
65
+ from .tools.openapi import OpenAPITool, openapi_toolset
66
+ from .types import Event, EventType, Message, RunContext, RunResult, Usage
67
+
68
+ __version__ = "0.1.0"
69
+
70
+ #: Which performance backend is active: ``"rust"`` or ``"python"``.
71
+ BACKEND = _core.backend()
72
+
73
+ __all__ = [
74
+ "__version__",
75
+ "BACKEND",
76
+ "Agent",
77
+ "Runner",
78
+ "tool",
79
+ "FunctionTool",
80
+ "AgentTool",
81
+ # multi-agent workflow patterns
82
+ "SequentialAgent",
83
+ "ParallelAgent",
84
+ "MapAgent",
85
+ "LoopAgent",
86
+ "Swarm",
87
+ # managers
88
+ "SessionManager",
89
+ "MemoryManager",
90
+ "ArtifactManager",
91
+ "State",
92
+ # extensibility
93
+ "register_component",
94
+ "get_component",
95
+ "available_components",
96
+ # eval metrics (built-in + RAGAS/DeepEval adapters)
97
+ "register_metric",
98
+ "get_metric",
99
+ "available_metrics",
100
+ # reusable building blocks
101
+ "Skill",
102
+ "PromptRegistry",
103
+ # RAG
104
+ "KnowledgeBase",
105
+ "Document",
106
+ # memory intelligence
107
+ "MemoryExtractor",
108
+ "KnowledgeBaseMemory",
109
+ # model intelligence
110
+ "ModelRouter",
111
+ # graph retry policies
112
+ "RetryPolicy",
113
+ # eval depth (portable evalsets + trajectory metric)
114
+ "EvalSet",
115
+ "EvalCase",
116
+ "ToolTrajectoryMatch",
117
+ # OpenAPI toolset
118
+ "openapi_toolset",
119
+ "OpenAPITool",
120
+ # tool-level auth (credentials + OAuth2 consent)
121
+ "ToolAuth",
122
+ "ToolCredential",
123
+ "ToolAuthRequired",
124
+ # run controls
125
+ "UsageLimits",
126
+ "CancellationToken",
127
+ # context-window management
128
+ "TruncateMessages",
129
+ "SummarizeHistory",
130
+ "KeepAll",
131
+ # declarative config
132
+ "agent_from_dict",
133
+ "agent_from_yaml",
134
+ "runner_from_dict",
135
+ # batch / offline inference
136
+ "batch_run",
137
+ "batch_map",
138
+ "batch_embed",
139
+ "RunContext",
140
+ "RunResult",
141
+ "Message",
142
+ "Content",
143
+ "Part",
144
+ "PartKind",
145
+ "Event",
146
+ "EventType",
147
+ "Usage",
148
+ # exceptions
149
+ "YaabError",
150
+ "ModelError",
151
+ "ToolError",
152
+ "OutputValidationError",
153
+ "MaxStepsExceeded",
154
+ "UsageLimitExceeded",
155
+ "RunCancelled",
156
+ "GovernanceError",
157
+ "PolicyViolation",
158
+ "ApprovalRequired",
159
+ ]
yaab/_core.py ADDED
@@ -0,0 +1,214 @@
1
+ """Performance-core shim.
2
+
3
+ Prefers the compiled Rust extension (:mod:`yaab_core`) and transparently falls
4
+ back to pure-Python implementations when the wheel is unavailable. The public
5
+ functions here are the *only* way the rest of the SDK touches the hot paths, so
6
+ the Rust/Python split is invisible to callers.
7
+
8
+ Inspect :data:`RUST` to know which backend is active.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import math
15
+ import os
16
+ from typing import Any
17
+
18
+ # Set YAAB_NO_RUST=1 to force the pure-Python fallback (used to keep the
19
+ # fallback path green in CI even when the wheel is installed).
20
+ if os.environ.get("YAAB_NO_RUST") == "1":
21
+ _rust = None
22
+ RUST = False
23
+ else:
24
+ try: # pragma: no cover - exercised by whichever backend is installed
25
+ import yaab_core as _rust
26
+
27
+ RUST = True
28
+ except ImportError: # pragma: no cover
29
+ _rust = None
30
+ RUST = False
31
+
32
+ __all__ = [
33
+ "RUST",
34
+ "backend",
35
+ "cosine_similarity",
36
+ "top_k",
37
+ "encode_checkpoint",
38
+ "decode_checkpoint",
39
+ "reduce_channel",
40
+ "advance_superstep",
41
+ "plan_supersteps",
42
+ "hash_event",
43
+ "verify_chain",
44
+ "aggregate_cost",
45
+ ]
46
+
47
+ _MAGIC = b"YAAB"
48
+ _VERSION = 1
49
+
50
+
51
+ def backend() -> str:
52
+ """Return ``"rust"`` or ``"python"`` for diagnostics and tests."""
53
+ return "rust" if RUST else "python"
54
+
55
+
56
+ def cosine_similarity(a: list[float], b: list[float]) -> float:
57
+ if RUST:
58
+ return _rust.cosine_similarity(list(a), list(b))
59
+ if len(a) != len(b) or not a:
60
+ return 0.0
61
+ dot = sum(x * y for x, y in zip(a, b, strict=False))
62
+ na = math.sqrt(sum(x * x for x in a))
63
+ nb = math.sqrt(sum(y * y for y in b))
64
+ if na == 0.0 or nb == 0.0:
65
+ return 0.0
66
+ return dot / (na * nb)
67
+
68
+
69
+ def top_k(query: list[float], matrix: list[list[float]], k: int) -> list[tuple[int, float]]:
70
+ if RUST:
71
+ return _rust.top_k(list(query), [list(r) for r in matrix], k)
72
+ scored = [(i, cosine_similarity(query, row)) for i, row in enumerate(matrix)]
73
+ scored.sort(key=lambda t: t[1], reverse=True)
74
+ return scored[:k]
75
+
76
+
77
+ def encode_checkpoint(state: Any) -> bytes:
78
+ """Serialize a JSON-compatible state object into a framed checkpoint blob."""
79
+ json_str = json.dumps(state, separators=(",", ":"), sort_keys=True)
80
+ if RUST:
81
+ return bytes(_rust.encode_checkpoint(json_str))
82
+ return _MAGIC + bytes([_VERSION]) + json_str.encode("utf-8")
83
+
84
+
85
+ def decode_checkpoint(blob: bytes) -> Any:
86
+ """Decode a framed checkpoint blob back into a Python object."""
87
+ if RUST:
88
+ return json.loads(_rust.decode_checkpoint(bytes(blob)))
89
+ if len(blob) < 5 or blob[0:4] != _MAGIC:
90
+ raise ValueError("invalid checkpoint: bad magic header")
91
+ if blob[4] != _VERSION:
92
+ raise ValueError(f"unsupported checkpoint version: {blob[4]}")
93
+ return json.loads(blob[5:].decode("utf-8"))
94
+
95
+
96
+ def reduce_channel(reducer: str, current: Any, update: Any) -> Any:
97
+ """Merge ``update`` into ``current`` under the named channel reducer."""
98
+ if RUST:
99
+ out = _rust.reduce_channel(
100
+ reducer,
101
+ json.dumps(current, separators=(",", ":")),
102
+ json.dumps(update, separators=(",", ":")),
103
+ )
104
+ return json.loads(out)
105
+ if reducer == "last_value":
106
+ return update
107
+ if reducer == "append":
108
+ base = (
109
+ list(current) if isinstance(current, list) else ([] if current is None else [current])
110
+ )
111
+ if isinstance(update, list):
112
+ base.extend(update)
113
+ else:
114
+ base.append(update)
115
+ return base
116
+ if reducer == "add":
117
+ a = current if isinstance(current, (int, float)) else 0
118
+ b = update if isinstance(update, (int, float)) else 0
119
+ return a + b
120
+ return update
121
+
122
+
123
+ def advance_superstep(
124
+ state: dict[str, Any],
125
+ reducers: dict[str, str],
126
+ updates: list[Any],
127
+ ) -> dict[str, Any]:
128
+ """Apply a whole superstep's node updates to ``state`` in one operation.
129
+
130
+ ``updates`` is a list of per-node update dicts (or ``None``), applied in
131
+ order; each key is reduced with the channel reducer named in ``reducers``
132
+ (defaulting to last-value). The Rust path does the entire fold in one call;
133
+ the pure-Python fallback reuses :func:`reduce_channel`.
134
+ """
135
+ if RUST:
136
+ out = _rust.advance_superstep(
137
+ json.dumps(state, separators=(",", ":")),
138
+ json.dumps(reducers, separators=(",", ":")),
139
+ json.dumps(updates, separators=(",", ":")),
140
+ )
141
+ return json.loads(out)
142
+ new_state = dict(state)
143
+ for update in updates:
144
+ if not update:
145
+ continue
146
+ for key, value in update.items():
147
+ reducer = reducers.get(key, "last_value")
148
+ new_state[key] = reduce_channel(reducer, new_state.get(key), value)
149
+ return new_state
150
+
151
+
152
+ def plan_supersteps(nodes: list[str], edges: list[tuple[str, str]]) -> list[list[str]]:
153
+ """Group nodes into BSP supersteps; trailing cycle members form a final step."""
154
+ if RUST:
155
+ return _rust.plan_supersteps(list(nodes), [tuple(e) for e in edges])
156
+ node_set = set(nodes)
157
+ indegree = {n: 0 for n in nodes}
158
+ adj: dict[str, list[str]] = {n: [] for n in nodes}
159
+ for src, dst in edges:
160
+ if src not in node_set or dst not in node_set or src == dst:
161
+ continue
162
+ adj[src].append(dst)
163
+ indegree[dst] += 1
164
+ layers: list[list[str]] = []
165
+ ready = [n for n, d in indegree.items() if d == 0]
166
+ visited: set[str] = set()
167
+ while ready:
168
+ layer = sorted(n for n in ready if n not in visited)
169
+ visited.update(layer)
170
+ nxt: list[str] = []
171
+ for node in ready:
172
+ for child in adj.get(node, []):
173
+ indegree[child] -= 1
174
+ if indegree[child] == 0 and child not in visited:
175
+ nxt.append(child)
176
+ if layer:
177
+ layers.append(layer)
178
+ ready = nxt
179
+ remaining = sorted(n for n in nodes if n not in visited)
180
+ if remaining:
181
+ layers.append(remaining)
182
+ return layers
183
+
184
+
185
+ def hash_event(prev_hash: str, payload: str) -> str:
186
+ """Chained audit hash: ``sha256(prev_hash || payload)``."""
187
+ if RUST:
188
+ return _rust.hash_event(prev_hash, payload)
189
+ import hashlib
190
+
191
+ h = hashlib.sha256()
192
+ h.update(prev_hash.encode("utf-8"))
193
+ h.update(b"\x1f")
194
+ h.update(payload.encode("utf-8"))
195
+ return h.hexdigest()
196
+
197
+
198
+ def verify_chain(genesis: str, entries: list[tuple[str, str]]) -> int | None:
199
+ """Return the index of the first broken hash-chain link, or ``None``."""
200
+ if RUST:
201
+ return _rust.verify_chain(genesis, [tuple(e) for e in entries])
202
+ prev = genesis
203
+ for i, (payload, recorded) in enumerate(entries):
204
+ expected = hash_event(prev, payload)
205
+ if expected != recorded:
206
+ return i
207
+ prev = expected
208
+ return None
209
+
210
+
211
+ def aggregate_cost(values: list[float]) -> float:
212
+ if RUST:
213
+ return _rust.aggregate_cost([float(v) for v in values])
214
+ return float(sum(values))
yaab/a2a/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """Agent-to-Agent (A2A) interop.
2
+
3
+ The server side lives in :mod:`yaab.serve` (agent card + ``/a2a/tasks``). This
4
+ package is the *client* side: :class:`RemoteAgent` discovers a remote agent via
5
+ its Agent Card and delegates tasks to it. A ``RemoteAgent`` satisfies both the
6
+ :class:`~yaab.tools.base.Tool` protocol (so it can be handed to a local agent as
7
+ a tool) and the workflow ``run`` surface (so it composes like any agent).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .client import RemoteAgent
13
+
14
+ __all__ = ["RemoteAgent"]
yaab/a2a/client.py ADDED
@@ -0,0 +1,170 @@
1
+ """A2A client — discover and delegate to a remote agent over HTTP.
2
+
3
+ ``httpx`` is an optional dependency, imported lazily. An injectable
4
+ ``transport`` callable (``method, path, json -> dict``) keeps the client fully
5
+ testable without a network — the test suite drives a real :func:`fastapi_server_app`
6
+ server through an in-process transport.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Awaitable, Callable
12
+ from typing import Any
13
+
14
+ from ..types import RunContext, RunResult, Usage
15
+
16
+ Transport = Callable[[str, str, dict | None], Awaitable[dict]]
17
+
18
+
19
+ class RemoteAgent:
20
+ """A handle to a remote A2A agent — usable as a tool or as an agent."""
21
+
22
+ def __init__(
23
+ self,
24
+ url: str,
25
+ *,
26
+ name: str | None = None,
27
+ auth_token: str | None = None,
28
+ token_provider: Callable[[], str] | None = None,
29
+ transport: Transport | None = None,
30
+ ) -> None:
31
+ self.url = url.rstrip("/")
32
+ self.name = name or "remote_agent"
33
+ self.auth_token = auth_token
34
+ # token_provider lets an OAuth2 client supply a fresh bearer token per
35
+ # request (token exchange / refresh handled by the caller's IdP client).
36
+ self.token_provider = token_provider
37
+ self._transport = transport
38
+ self._card: dict[str, Any] | None = None
39
+
40
+ # --- transport -----------------------------------------------------
41
+ def _headers(self) -> dict[str, str]:
42
+ token = self.token_provider() if self.token_provider is not None else self.auth_token
43
+ return {"Authorization": f"Bearer {token}"} if token else {}
44
+
45
+ async def _request(self, method: str, path: str, json: dict | None = None) -> dict:
46
+ if self._transport is not None:
47
+ return await self._transport(method, path, json)
48
+ try:
49
+ import httpx
50
+ except ImportError as exc: # pragma: no cover - optional extra
51
+ raise RuntimeError(
52
+ "httpx is required for the A2A client. `pip install httpx`."
53
+ ) from exc
54
+ async with httpx.AsyncClient() as client:
55
+ resp = await client.request(
56
+ method, f"{self.url}{path}", json=json, headers=self._headers()
57
+ )
58
+ resp.raise_for_status()
59
+ return resp.json()
60
+
61
+ # --- discovery -----------------------------------------------------
62
+ async def fetch_card(self, *, refresh: bool = False) -> dict[str, Any]:
63
+ """Fetch (and cache) the remote Agent Card."""
64
+ if self._card is None or refresh:
65
+ self._card = await self._request("GET", "/.well-known/agent.json", None)
66
+ if not self._card.get("name"):
67
+ self._card["name"] = self.name
68
+ else:
69
+ self.name = self.name or self._card["name"]
70
+ return self._card
71
+
72
+ # --- delegation ----------------------------------------------------
73
+ async def run(
74
+ self,
75
+ prompt: str,
76
+ *,
77
+ deps: Any = None,
78
+ session_id: str | None = None,
79
+ identity: str | None = None,
80
+ ) -> RunResult[str]:
81
+ """Send the prompt as an A2A task and return the remote agent's output."""
82
+ body = {"message": {"role": "user", "parts": [{"text": prompt}]}}
83
+ task = await self._request("POST", "/a2a/tasks", body)
84
+ text = _extract_text(task)
85
+ return RunResult(output=text, usage=Usage(requests=1), run_id=task.get("id", ""))
86
+
87
+ def run_sync(self, prompt: str, **kwargs: Any) -> RunResult[str]:
88
+ import asyncio
89
+
90
+ return asyncio.run(self.run(prompt, **kwargs))
91
+
92
+ async def get_task(self, task_id: str) -> dict[str, Any]:
93
+ """Poll a long-running task by id (returns the A2A task object)."""
94
+ return await self._request("GET", f"/a2a/tasks/{task_id}", None)
95
+
96
+ #: A2A task states considered terminal (stop polling).
97
+ TERMINAL_STATES = frozenset({"completed", "failed", "canceled", "cancelled", "rejected"})
98
+
99
+ async def poll_task(
100
+ self,
101
+ task_id: str,
102
+ *,
103
+ interval: float = 1.0,
104
+ timeout: float | None = 60.0,
105
+ terminal_states: set[str] | frozenset[str] | None = None,
106
+ ) -> dict[str, Any]:
107
+ """Poll a long-running remote task until it reaches a terminal state.
108
+
109
+ Returns the final task object. Raises :class:`TimeoutError` if the task
110
+ has not finished within ``timeout`` seconds (``None`` = no timeout).
111
+ ``interval`` is the delay between polls.
112
+ """
113
+ import asyncio
114
+ import time
115
+
116
+ terminal = terminal_states or self.TERMINAL_STATES
117
+ started = time.monotonic()
118
+ while True:
119
+ task = await self.get_task(task_id)
120
+ state = (task.get("status") or {}).get("state")
121
+ if state in terminal:
122
+ return task
123
+ if timeout is not None and time.monotonic() - started > timeout:
124
+ raise TimeoutError(
125
+ f"A2A task {task_id!r} did not reach a terminal state within {timeout}s "
126
+ f"(last state: {state!r})"
127
+ )
128
+ if interval:
129
+ await asyncio.sleep(interval)
130
+
131
+ # --- Tool protocol -------------------------------------------------
132
+ @property
133
+ def description(self) -> str:
134
+ if self._card:
135
+ return self._card.get("description", f"Remote agent at {self.url}")
136
+ return f"Remote A2A agent at {self.url}"
137
+
138
+ def schema(self) -> dict[str, Any]:
139
+ return {
140
+ "type": "function",
141
+ "function": {
142
+ "name": self.name,
143
+ "description": self.description,
144
+ "parameters": {
145
+ "type": "object",
146
+ "properties": {
147
+ "prompt": {"type": "string", "description": "Task for the remote agent."}
148
+ },
149
+ "required": ["prompt"],
150
+ },
151
+ },
152
+ }
153
+
154
+ async def execute(self, ctx: RunContext, *, prompt: str) -> Any:
155
+ result = await self.run(prompt, identity=ctx.identity)
156
+ ctx.usage.add(result.usage)
157
+ return result.output
158
+
159
+
160
+ def _extract_text(task: dict[str, Any]) -> str:
161
+ """Pull the text out of an A2A task's artifacts."""
162
+ parts_text: list[str] = []
163
+ for artifact in task.get("artifacts", []):
164
+ for part in artifact.get("parts", []):
165
+ if "text" in part:
166
+ parts_text.append(part["text"])
167
+ return "\n".join(parts_text)
168
+
169
+
170
+ __all__ = ["RemoteAgent"]