framework-agents 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 (41) hide show
  1. framework_agents/__init__.py +345 -0
  2. framework_agents/a2a_client.py +360 -0
  3. framework_agents/a2a_server.py +495 -0
  4. framework_agents/agent.py +920 -0
  5. framework_agents/checkpoint.py +231 -0
  6. framework_agents/config.py +200 -0
  7. framework_agents/conversation.py +249 -0
  8. framework_agents/errors.py +47 -0
  9. framework_agents/evals.py +127 -0
  10. framework_agents/mcp_client.py +477 -0
  11. framework_agents/memory.py +103 -0
  12. framework_agents/memory_compaction.py +63 -0
  13. framework_agents/middleware.py +1302 -0
  14. framework_agents/models/__init__.py +100 -0
  15. framework_agents/models/anthropic.py +339 -0
  16. framework_agents/models/capabilities.py +45 -0
  17. framework_agents/models/ollama.py +47 -0
  18. framework_agents/models/openai_compatible.py +409 -0
  19. framework_agents/observability.py +84 -0
  20. framework_agents/ontology_review.py +321 -0
  21. framework_agents/orchestration.py +169 -0
  22. framework_agents/pricing.py +63 -0
  23. framework_agents/prompts.py +132 -0
  24. framework_agents/rag.py +123 -0
  25. framework_agents/remote_sparql.py +213 -0
  26. framework_agents/runtime.py +88 -0
  27. framework_agents/sandbox.py +327 -0
  28. framework_agents/semantic.py +1614 -0
  29. framework_agents/serve.py +436 -0
  30. framework_agents/sessions.py +249 -0
  31. framework_agents/skills.py +213 -0
  32. framework_agents/state.py +89 -0
  33. framework_agents/streaming.py +74 -0
  34. framework_agents/swarm.py +318 -0
  35. framework_agents/tools.py +114 -0
  36. framework_agents/tracing.py +131 -0
  37. framework_agents/types.py +262 -0
  38. framework_agents/workflows.py +130 -0
  39. framework_agents-0.1.0.dist-info/METADATA +118 -0
  40. framework_agents-0.1.0.dist-info/RECORD +41 -0
  41. framework_agents-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,345 @@
1
+ """framework_agents — Python parity port of packages/typescript.
2
+
3
+ Import parity with the TS package (barrel over the whole public surface):
4
+
5
+ from framework_agents import Agent, AgentOptions, define_tool, ...
6
+ from framework_agents.semantic import InMemoryGraphStore
7
+ from framework_agents.middleware import human_in_the_loop
8
+
9
+ See packages/python/README.md for the port status matrix.
10
+ """
11
+
12
+ from .agent import (
13
+ Agent,
14
+ AgentEvents,
15
+ AgentOptions,
16
+ AgentResult,
17
+ ResponseFormat,
18
+ TokenBudgets,
19
+ provider_strategy,
20
+ tool_strategy,
21
+ )
22
+ from .a2a_client import A2AClient, AgentClient, AgentClientOptions, a2a_as_tool
23
+ from .a2a_server import A2AServer
24
+ from .checkpoint import Checkpointer, FileCheckpointer, ThreadCheckpoint, with_file_lock
25
+ from .config import init_chat_model, load_config
26
+ from .conversation import (
27
+ NullConversationManager,
28
+ SlidingWindowConversationManager,
29
+ SummarizingConversationManager,
30
+ estimate_tokens,
31
+ history_tokens,
32
+ trim_dangling,
33
+ )
34
+ from .errors import AgentError, get_retryable, stamp_retryable
35
+ from .evals import EvalCase, EvalScore, load_jsonl, print_report, run_evals
36
+ from .memory import FileMemory, InMemoryMemory
37
+ from .memory_compaction import CompactedMemory
38
+ from .middleware import (
39
+ ClearToolUsesEdit,
40
+ HitlInterrupt,
41
+ ToolRejection,
42
+ context_editing_middleware,
43
+ create_middleware,
44
+ create_sub_agent_middleware,
45
+ filesystem_middleware,
46
+ filesystem_tools,
47
+ hitl_key,
48
+ human_in_the_loop,
49
+ llm_tool_selector_middleware,
50
+ model_call_limit,
51
+ model_fallback,
52
+ model_retry,
53
+ pii_redact,
54
+ policy_filter,
55
+ summarization_middleware,
56
+ todo_list,
57
+ tool_call_limit,
58
+ tool_emulator_middleware,
59
+ tool_retry,
60
+ )
61
+ from .models import (
62
+ CompleteOptions,
63
+ MockModelProvider,
64
+ ModelCompletion,
65
+ ModelProvider,
66
+ ResponseFormatWire,
67
+ ScriptedModelProvider,
68
+ ToolCallRequest,
69
+ ToolWire,
70
+ )
71
+ from .models.capabilities import assert_can_handle, supports_vision
72
+ from .models.ollama import OllamaOptions, OllamaProvider, ollama
73
+ from .models.anthropic import AnthropicOptions, AnthropicProvider
74
+ from .models.openai_compatible import (
75
+ OpenAICompatibleOptions,
76
+ OpenAICompatibleProvider,
77
+ provider_from_env,
78
+ )
79
+ from .observability import console_events, log_error
80
+ from .pricing import ModelPrice, price_for, usage_cost
81
+ from .prompts import PromptTemplate, ab_prompts, load_prompt_file
82
+ from .rag import InMemoryRetriever, retrieval_tool
83
+ from .runtime import AbortHandle, Budget, run_with_budget
84
+ from .sandbox import create_sandbox_backend, run_javascript_tool
85
+ from .sessions import (
86
+ InMemoryStorage,
87
+ LocalFileStorage,
88
+ SessionManager,
89
+ Snapshot,
90
+ select_snapshot_fields,
91
+ )
92
+ from .state import AgentState
93
+ from .streaming import StreamEvent, collect_stream
94
+ from .skills import SkillRegistry, discover_skills, match_skills, parse_skill_file, score_skills
95
+ from .tools import (
96
+ Tool,
97
+ ToolContext,
98
+ ToolRegistry,
99
+ ToolResult,
100
+ define_tool,
101
+ parameters_schema,
102
+ )
103
+ from .tracing import (
104
+ ConsoleSpanSink,
105
+ JsonlSpanSink,
106
+ MemorySpanSink,
107
+ Span,
108
+ SpanSink,
109
+ tracing_events,
110
+ )
111
+ from .types import (
112
+ ChatMessage,
113
+ ExecutedToolCall,
114
+ TokenUsage,
115
+ assistant,
116
+ deserialize_messages,
117
+ has_images,
118
+ parse_content_block,
119
+ serialize_messages,
120
+ system,
121
+ text_of,
122
+ tool_result,
123
+ user,
124
+ wire_text_of,
125
+ )
126
+ from .workflows import WorkflowNode, WorkflowOptions, run_workflow
127
+
128
+ # Subpackages exported lazily to keep import cost down? — python imports are
129
+ # already module-level and cheap here; semantic/mcp/swarm/orchestration are
130
+ # exported as modules so the public surface mirrors the TS barrel without
131
+ # starving startup.
132
+ from . import ( # noqa: E402
133
+ conversation,
134
+ mcp_client,
135
+ middleware,
136
+ orchestration,
137
+ semantic,
138
+ swarm,
139
+ )
140
+
141
+ from .mcp_client import (
142
+ McpHttpClient,
143
+ McpStdioClient,
144
+ connect_mcp_http,
145
+ connect_mcp_stdio,
146
+ mcp_tools_to_framework,
147
+ )
148
+ from .orchestration import OrchestrationPlan, OrchestratedResult, run_orchestrated
149
+ from .ontology_review import adopt_shapes, propose_shapes, review_ontology
150
+ from .semantic import (
151
+ EntityLinker,
152
+ GraphMemory,
153
+ InMemoryGraphStore,
154
+ MappingProposals,
155
+ NodeShape,
156
+ Quad,
157
+ execute_select,
158
+ json_ld_to_quads,
159
+ migrate_t_box,
160
+ quads_to_json_ld,
161
+ run_conformance,
162
+ )
163
+ from .remote_sparql import RemoteSparqlAdapter, remote_sparql_tool
164
+ from .serve import (
165
+ FrameworkFile,
166
+ LoadedFrameworkFile,
167
+ build_served_agent,
168
+ load_framework_config,
169
+ serve_framework,
170
+ )
171
+ from .swarm import Swarm, SwarmResult
172
+ from .ontology_review import propose_shapes_tool, review_ontology_tool # noqa: E402
173
+
174
+ __all__ = [
175
+ "AbortHandle",
176
+ "A2AClient",
177
+ "AgentState",
178
+ "AnthropicOptions",
179
+ "AnthropicProvider",
180
+ "A2AServer",
181
+ "Agent",
182
+ "AgentClient",
183
+ "AgentClientOptions",
184
+ "AgentError",
185
+ "AgentEvents",
186
+ "AgentOptions",
187
+ "AgentResult",
188
+ "AgentState",
189
+ "Budget",
190
+ "ClearToolUsesEdit",
191
+ "FrameworkFile",
192
+ "LoadedFrameworkFile",
193
+ "ChatMessage",
194
+ "Checkpointer",
195
+ "CompactedMemory",
196
+ "ConsoleSpanSink",
197
+ "EntityLinker",
198
+ "EvalCase",
199
+ "EvalScore",
200
+ "ExecutedToolCall",
201
+ "FileCheckpointer",
202
+ "FileMemory",
203
+ "GraphMemory",
204
+ "HitlInterrupt",
205
+ "InMemoryGraphStore",
206
+ "InMemoryRetriever",
207
+ "InMemoryStorage",
208
+ "InMemoryMemory",
209
+ "JsonlSpanSink",
210
+ "LocalFileStorage",
211
+ "MappingProposals",
212
+ "MemorySpanSink",
213
+ "McpHttpClient",
214
+ "McpStdioClient",
215
+ "MockModelProvider",
216
+ "ModelCompletion",
217
+ "ModelPrice",
218
+ "ModelProvider",
219
+ "NodeShape",
220
+ "NullConversationManager",
221
+ "OllamaOptions",
222
+ "OllamaProvider",
223
+ "OpenAICompatibleOptions",
224
+ "OpenAICompatibleProvider",
225
+ "OrchestrationPlan",
226
+ "OrchestratedResult",
227
+ "PromptTemplate",
228
+ "Quad",
229
+ "ResponseFormat",
230
+ "ResponseFormatWire",
231
+ "ScriptedModelProvider",
232
+ "SessionManager",
233
+ "SlidingWindowConversationManager",
234
+ "Snapshot",
235
+ "Span",
236
+ "SpanSink",
237
+ "StreamEvent",
238
+ "SummarizingConversationManager",
239
+ "Swarm",
240
+ "SwarmResult",
241
+ "ThreadCheckpoint",
242
+ "TokenBudgets",
243
+ "TokenUsage",
244
+ "Tool",
245
+ "ToolCallRequest",
246
+ "ToolContext",
247
+ "ToolRegistry",
248
+ "ToolRejection",
249
+ "ToolResult",
250
+ "ToolWire",
251
+ "WorkflowNode",
252
+ "WorkflowOptions",
253
+ "ab_prompts",
254
+ "a2a_as_tool",
255
+ "assert_can_handle",
256
+ "collect_stream",
257
+ "assistant",
258
+ "connect_mcp_http",
259
+ "context_editing_middleware",
260
+ "create_sub_agent_middleware",
261
+ "connect_mcp_stdio",
262
+ "console_events",
263
+ "adopt_shapes",
264
+ "create_middleware",
265
+ "create_sandbox_backend",
266
+ "define_tool",
267
+ "deserialize_messages",
268
+ "discover_skills",
269
+ "estimate_tokens",
270
+ "execute_select",
271
+ "get_retryable",
272
+ "has_images",
273
+ "filesystem_middleware",
274
+ "filesystem_tools",
275
+ "history_tokens",
276
+ "hitl_key",
277
+ "human_in_the_loop",
278
+ "init_chat_model",
279
+ "json_ld_to_quads",
280
+ "load_framework_config",
281
+ "load_config",
282
+ "load_jsonl",
283
+ "load_prompt_file",
284
+ "log_error",
285
+ "match_skills",
286
+ "mcp_tools_to_framework",
287
+ "migrate_t_box",
288
+ "llm_tool_selector_middleware",
289
+ "model_fallback",
290
+ "model_call_limit",
291
+ "model_retry",
292
+ "ollama",
293
+ "parse_content_block",
294
+ "parse_skill_file",
295
+ "parameters_schema",
296
+ "pii_redact",
297
+ "policy_filter",
298
+ "price_for",
299
+ "provider_from_env",
300
+ "print_report",
301
+ "propose_shapes",
302
+ "propose_shapes_tool",
303
+ "quads_to_json_ld",
304
+ "remote_sparql_tool",
305
+ "RemoteSparqlAdapter",
306
+ "retrieval_tool",
307
+ "review_ontology",
308
+ "review_ontology_tool",
309
+ "run_conformance",
310
+ "run_evals",
311
+ "serve_framework",
312
+ "build_served_agent",
313
+ "run_javascript_tool",
314
+ "run_orchestrated",
315
+ "run_with_budget",
316
+ "run_workflow",
317
+ "summarization_middleware",
318
+ "score_skills",
319
+ "supports_vision",
320
+ "select_snapshot_fields",
321
+ "serialize_messages",
322
+ "stamp_retryable",
323
+ "system",
324
+ "text_of",
325
+ "tool_emulator_middleware",
326
+ "todo_list",
327
+ "tool_call_limit",
328
+ "tool_result",
329
+ "tool_retry",
330
+ "tool_strategy",
331
+ "tracing_events",
332
+ "trim_dangling",
333
+ "usage_cost",
334
+ "user",
335
+ "provider_strategy",
336
+ "wire_text_of",
337
+ "with_file_lock",
338
+ # module handles
339
+ "conversation",
340
+ "mcp_client",
341
+ "middleware",
342
+ "orchestration",
343
+ "semantic",
344
+ "swarm",
345
+ ]
@@ -0,0 +1,360 @@
1
+ """A2A wire client. Parity: src/client/index.ts + src/a2a/index.ts (fixed).
2
+
3
+ Pinned contract (the TS client once POSTed without the JSON-RPC envelope and
4
+ the server ran every stream on an EMPTY message):
5
+
6
+ - AgentClient.stream POSTs `{jsonrpc, id, method: "message/stream", params:
7
+ {message: {parts}, contextId?}}` to `${endpoint}/stream`, Accept SSE.
8
+ - SSE frames: `{"contextId", "event"}` / `{"contextId", "done", "text"}` /
9
+ `{"error"}`; `[DONE]` terminates. on_context receives the server-assigned
10
+ id so multi-turn continuation works.
11
+ - A2AClient.send POSTs `message/send` to the endpoint root and reads the
12
+ server-assigned contextId from RESULT (never made up by the caller).
13
+ - AgentClientOptions.timeout_ms (default 300s) governs every send/get_card/
14
+ stream (httpx.Timeout, TS AbortSignal.timeout parity — httpx's 5s default
15
+ never applies); AgentClient.stream's `signal` aborts mid-stream (the
16
+ streaming response is closed and the generator returns).
17
+ - Network/5xx retry with Retry-After honored (public endpoints rate-limit).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import inspect
24
+ import itertools
25
+ import json
26
+ from dataclasses import dataclass
27
+ from typing import Any, AsyncIterator, Callable, Optional
28
+
29
+ import httpx
30
+
31
+ from .errors import AgentError
32
+
33
+ _sseq = itertools.count(1)
34
+
35
+
36
+ @dataclass
37
+ class AgentClientOptions:
38
+ fetch_fn: Callable[[], httpx.AsyncClient] | None = None # injectable for tests
39
+ timeout_ms: float = 300_000.0
40
+
41
+
42
+ def _client_with_timeout(client: Any, opts: AgentClientOptions) -> Any:
43
+ """Apply ``opts.timeout_ms`` to the whole exchange (TS AbortSignal.timeout
44
+ parity) — without this httpx's own 5s default governs every send/get_card/
45
+ stream, silently ignoring the 300s the options promise."""
46
+ try:
47
+ client.timeout = httpx.Timeout(opts.timeout_ms / 1000.0)
48
+ except (AttributeError, TypeError, ValueError):
49
+ pass # injected test double without a settable timeout: leave as-is
50
+ return client
51
+
52
+
53
+ def _signal_fired(signal: Any) -> bool:
54
+ """Sync "is it aborted?" probe (asyncio.Event / Future / Task, or any
55
+ object exposing ``is_set()`` / ``done()``)."""
56
+ check = getattr(signal, "is_set", None)
57
+ if callable(check):
58
+ return bool(check())
59
+ done = getattr(signal, "done", None)
60
+ if callable(done):
61
+ return bool(done())
62
+ return False
63
+
64
+
65
+ async def _await_signal(signal: Any) -> None:
66
+ """Block until the caller's abort signal fires: asyncio.Event / Future /
67
+ Task, a callable returning an awaitable, or (fallback) any flag that
68
+ ``_signal_fired`` understands, polled off the event loop."""
69
+ if isinstance(signal, asyncio.Event):
70
+ await signal.wait()
71
+ elif isinstance(signal, (asyncio.Future, asyncio.Task)):
72
+ await signal
73
+ elif callable(signal):
74
+ value = signal()
75
+ if inspect.isawaitable(value):
76
+ await value
77
+ else:
78
+ while not _signal_fired(signal):
79
+ await asyncio.sleep(0.02)
80
+
81
+
82
+ @dataclass
83
+ class _RetryPolicy:
84
+ max_retries: int = 2
85
+
86
+
87
+ class _HttpRetry:
88
+ """429/5xx/network retry honoring Retry-After (shared by both clients)."""
89
+
90
+ def __init__(self, policy: _RetryPolicy | None = None) -> None:
91
+ self.policy = policy or _RetryPolicy()
92
+
93
+ async def request(
94
+ self,
95
+ client: httpx.AsyncClient,
96
+ method: str,
97
+ url: str,
98
+ stream: bool = False,
99
+ **kwargs: Any,
100
+ ) -> httpx.Response:
101
+ last: AgentError | None = None
102
+ for attempt in range(self.policy.max_retries + 1):
103
+ if attempt > 0:
104
+ delay = self._delay_ms or min(2000 * 2 ** (attempt - 1), 10_000)
105
+ import asyncio
106
+
107
+ await asyncio.sleep(delay / 1000)
108
+ try:
109
+ # Streaming: build the request, send without consuming the
110
+ # body (aiter_text works); retries only fire on status codes,
111
+ # which are available before any body bytes are pulled.
112
+ if stream:
113
+ built = client.build_request(method, url, **kwargs)
114
+ res = await client.send(built, stream=True)
115
+ else:
116
+ res = await client.request(method, url, **kwargs)
117
+ except Exception as err:
118
+ last = AgentError("A2A_TRANSPORT", f"Request failed: {err}", retryable=True)
119
+ self._delay_ms = None
120
+ continue
121
+ if res.status_code == 429 or res.status_code >= 500:
122
+ header = res.headers.get("retry-after", "")
123
+ try:
124
+ self._delay_ms = min(float(header) * 1000, 60_000)
125
+ except ValueError:
126
+ self._delay_ms = None
127
+ if stream:
128
+ # A streamed failed attempt holds its connection until
129
+ # closed; without this aclose() every retry would leak a
130
+ # pool slot (the response body was never pulled). Its
131
+ # body is unread (and now closed) — the status line
132
+ # alone becomes the error (reading .text would raise).
133
+ try:
134
+ await res.aclose()
135
+ except Exception: # noqa: BLE001 - best-effort cleanup
136
+ pass
137
+ last = AgentError("A2A_TRANSPORT", f"HTTP {res.status_code}", retryable=True)
138
+ else:
139
+ last = AgentError("A2A_TRANSPORT", f"HTTP {res.status_code}: {res.text[:300]}", retryable=True)
140
+ continue
141
+ return res
142
+ raise last or AgentError("A2A_TRANSPORT", "request failed", retryable=True)
143
+
144
+ _delay_ms: float | None = None
145
+
146
+
147
+ class AgentClient:
148
+ """SSE streaming client against a Framework agent server (`/stream`)."""
149
+
150
+ def __init__(self, endpoint: str, opts: AgentClientOptions | None = None) -> None:
151
+ self.opts = opts or AgentClientOptions()
152
+ self.endpoint = endpoint.rstrip("/")
153
+
154
+ async def stream(
155
+ self,
156
+ text: str,
157
+ context_id: Optional[str] = None,
158
+ signal: Optional[Any] = None,
159
+ on_context: Optional[Callable[[str], None]] = None,
160
+ ) -> AsyncIterator[dict[str, Any]]:
161
+ """Yield SSE event frames; ``signal`` (e.g. an asyncio.Event) ABORTS
162
+ the pull mid-stream: the streaming response is closed, upstream ends,
163
+ and the generator returns cleanly."""
164
+ retry = _HttpRetry()
165
+ factory = self.opts.fetch_fn or httpx.AsyncClient
166
+ client = _client_with_timeout(factory(), self.opts)
167
+ try:
168
+ body = {
169
+ "jsonrpc": "2.0",
170
+ "id": f"a2a-{next(_sseq)}",
171
+ "method": "message/stream",
172
+ "params": {
173
+ "message": {"role": "user", "parts": [{"kind": "text", "text": text}]},
174
+ **({"contextId": context_id} if context_id else {}),
175
+ },
176
+ }
177
+ res = await retry.request(
178
+ client,
179
+ "POST",
180
+ f"{self.endpoint}/stream",
181
+ stream=True,
182
+ headers={"content-type": "application/json", "accept": "text/event-stream"},
183
+ json=body,
184
+ )
185
+ if res.status_code >= 400:
186
+ raise AgentError("A2A_TRANSPORT", f"Stream HTTP {res.status_code}", retryable=res.status_code >= 500)
187
+ abort_state: dict[str, bool] = {"aborted": False}
188
+
189
+ async def watchdog() -> None:
190
+ try:
191
+ await _await_signal(signal)
192
+ except asyncio.CancelledError:
193
+ return
194
+ abort_state["aborted"] = True
195
+ # Abort stops the pull: tearing down the streaming response
196
+ # ends the exchange; upstream observes the closed connection.
197
+ try:
198
+ await res.aclose()
199
+ except Exception: # noqa: BLE001 - best-effort teardown
200
+ pass
201
+
202
+ watcher: Optional["asyncio.Task[None]"] = None
203
+ if signal is not None:
204
+ watcher = asyncio.ensure_future(watchdog())
205
+
206
+ def _aborted() -> bool:
207
+ return abort_state["aborted"] or _signal_fired(signal)
208
+
209
+ buffer = ""
210
+ lines: list[str] = []
211
+ try:
212
+ async for chunk in res.aiter_text():
213
+ if watcher is not None and _aborted():
214
+ return
215
+ buffer += chunk
216
+ (lines, buffer) = (buffer.split("\n")[:-1], buffer.split("\n")[-1])
217
+ for line in lines:
218
+ stripped = line.strip()
219
+ if not stripped.startswith("data:"):
220
+ continue
221
+ payload = stripped[5:].strip()
222
+ # The server writes [DONE] BOTH ways on the wire (raw in
223
+ # TS tests, JSON-stringified by the TS server's writer).
224
+ if payload in ("[DONE]", "\"[DONE]\""):
225
+ return
226
+ try:
227
+ frame = json.loads(payload)
228
+ except ValueError:
229
+ continue
230
+ if not isinstance(frame, dict):
231
+ continue # tolerate str/number frames
232
+ if frame.get("contextId") and on_context:
233
+ on_context(frame["contextId"])
234
+ if frame.get("error"):
235
+ raise AgentError("A2A_ERROR", str(frame["error"]), retryable=False)
236
+ event = frame.get("event")
237
+ if isinstance(event, dict) and isinstance(event.get("type"), str):
238
+ yield event
239
+ # The signal may fire while we are suspended at the
240
+ # yield; the next pull must abort, not continue.
241
+ if watcher is not None and _aborted():
242
+ return
243
+ except httpx.TimeoutException as err:
244
+ raise AgentError("A2A_TRANSPORT", f"Stream timed out: {err}", retryable=True) from err
245
+ except (httpx.HTTPError, RuntimeError):
246
+ if abort_state["aborted"]:
247
+ return # the watchdog closed the stream: a clean abort
248
+ raise
249
+ finally:
250
+ if watcher is not None:
251
+ watcher.cancel()
252
+ try:
253
+ await watcher
254
+ except asyncio.CancelledError:
255
+ pass
256
+ except Exception: # noqa: BLE001 - watchdog cleanup is best-effort
257
+ pass
258
+ finally:
259
+ if self.opts.fetch_fn is None:
260
+ await client.aclose()
261
+ elif hasattr(client, "aclose"):
262
+ await client.aclose()
263
+
264
+
265
+ class A2AClient:
266
+ """JSON-RPC message/send client (cards + non-streaming sends)."""
267
+
268
+ def __init__(self, endpoint: str, opts: AgentClientOptions | None = None) -> None:
269
+ self.opts = opts or AgentClientOptions()
270
+ self.endpoint = endpoint.rstrip("/")
271
+
272
+ async def get_card(self) -> dict[str, Any]:
273
+ retry = _HttpRetry()
274
+ client = _client_with_timeout((self.opts.fetch_fn or httpx.AsyncClient)(), self.opts)
275
+ try:
276
+ res = await retry.request(client, "GET", f"{self.endpoint}/.well-known/agent-card.json")
277
+ if res.status_code >= 400:
278
+ raise AgentError("A2A_CARD", f"Agent card fetch failed: HTTP {res.status_code}", retryable=res.status_code >= 500)
279
+ card = res.json()
280
+ if not card.get("name"):
281
+ raise AgentError("A2A_CARD", "Invalid agent card (missing name)", retryable=False)
282
+ return card
283
+ finally:
284
+ if hasattr(client, "aclose"):
285
+ await client.aclose()
286
+
287
+ async def send(self, text: str, context_id: Optional[str] = None) -> dict[str, Any]:
288
+ retry = _HttpRetry()
289
+ client = _client_with_timeout((self.opts.fetch_fn or httpx.AsyncClient)(), self.opts)
290
+ try:
291
+ body = {
292
+ "jsonrpc": "2.0",
293
+ "id": f"a2a-{next(_sseq)}",
294
+ "method": "message/send",
295
+ "params": {
296
+ "message": {"role": "user", "parts": [{"kind": "text", "text": text}]},
297
+ **({"contextId": context_id} if context_id else {}),
298
+ },
299
+ }
300
+ res = await retry.request(
301
+ client,
302
+ "POST",
303
+ self.endpoint,
304
+ headers={"content-type": "application/json", "accept": "application/json"},
305
+ json=body,
306
+ )
307
+ if res.status_code >= 400:
308
+ raise AgentError("A2A_TRANSPORT", f"A2A HTTP {res.status_code}", retryable=res.status_code >= 500)
309
+ rpc = res.json()
310
+ if rpc.get("error"):
311
+ raise AgentError("A2A_ERROR", str(rpc["error"].get("message", "error")), retryable=False)
312
+ # Servers return the (possibly newly minted) contextId inside result.
313
+ result = rpc.get("result") or {}
314
+ return {
315
+ "text": _text_of_parts(((result.get("message") or {}).get("parts")) or []),
316
+ "contextId": result.get("contextId") or context_id,
317
+ }
318
+ finally:
319
+ if hasattr(client, "aclose"):
320
+ await client.aclose()
321
+
322
+
323
+ def _text_of_parts(parts: list[Any]) -> str:
324
+ out = []
325
+ for part in parts:
326
+ if isinstance(part, dict) and isinstance(part.get("text"), str):
327
+ out.append(part["text"])
328
+ return "".join(out)
329
+
330
+
331
+ def a2a_as_tool(
332
+ client: A2AClient,
333
+ name: str = "remote_agent",
334
+ description: Optional[str] = None,
335
+ ) -> "Tool":
336
+ """Wrap a remote agent as a framework Tool for orchestrators.
337
+
338
+ Parity: TS a2aAsTool — execute returns the ``.text`` of ``client.send``;
339
+ ``context_id`` (optional) keeps the remote conversation going.
340
+ """
341
+ from pydantic import create_model
342
+
343
+ from .tools import define_tool
344
+
345
+ _A2aInput = create_model(
346
+ "A2aInput",
347
+ input=(str, ...),
348
+ context_id=(Optional[str], None),
349
+ )
350
+
351
+ async def execute(args: Any, ctx: Any = None) -> Any:
352
+ result = await client.send(args.input, args.context_id)
353
+ return result["text"]
354
+
355
+ return define_tool(
356
+ name=name,
357
+ description=description or f"Remote A2A agent at {client.endpoint}",
358
+ schema=_A2aInput,
359
+ execute=execute,
360
+ )