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.
- framework_agents/__init__.py +345 -0
- framework_agents/a2a_client.py +360 -0
- framework_agents/a2a_server.py +495 -0
- framework_agents/agent.py +920 -0
- framework_agents/checkpoint.py +231 -0
- framework_agents/config.py +200 -0
- framework_agents/conversation.py +249 -0
- framework_agents/errors.py +47 -0
- framework_agents/evals.py +127 -0
- framework_agents/mcp_client.py +477 -0
- framework_agents/memory.py +103 -0
- framework_agents/memory_compaction.py +63 -0
- framework_agents/middleware.py +1302 -0
- framework_agents/models/__init__.py +100 -0
- framework_agents/models/anthropic.py +339 -0
- framework_agents/models/capabilities.py +45 -0
- framework_agents/models/ollama.py +47 -0
- framework_agents/models/openai_compatible.py +409 -0
- framework_agents/observability.py +84 -0
- framework_agents/ontology_review.py +321 -0
- framework_agents/orchestration.py +169 -0
- framework_agents/pricing.py +63 -0
- framework_agents/prompts.py +132 -0
- framework_agents/rag.py +123 -0
- framework_agents/remote_sparql.py +213 -0
- framework_agents/runtime.py +88 -0
- framework_agents/sandbox.py +327 -0
- framework_agents/semantic.py +1614 -0
- framework_agents/serve.py +436 -0
- framework_agents/sessions.py +249 -0
- framework_agents/skills.py +213 -0
- framework_agents/state.py +89 -0
- framework_agents/streaming.py +74 -0
- framework_agents/swarm.py +318 -0
- framework_agents/tools.py +114 -0
- framework_agents/tracing.py +131 -0
- framework_agents/types.py +262 -0
- framework_agents/workflows.py +130 -0
- framework_agents-0.1.0.dist-info/METADATA +118 -0
- framework_agents-0.1.0.dist-info/RECORD +41 -0
- 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
|
+
)
|