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,100 @@
1
+ """Model layer. Parity: src/models/index.ts.
2
+
3
+ Pinned semantics:
4
+ - CompleteOptions.tools carries `parameters_schema` (JSON Schema) — models
5
+ must see real argument shapes; omit only for zero-arg tools.
6
+ - ModelCompletion.finish_reason is exposed when the provider reports it
7
+ (truncation observable).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import itertools
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, Callable, Protocol, runtime_checkable
15
+
16
+ from ..types import ChatMessage, TokenUsage
17
+
18
+
19
+ @dataclass
20
+ class ToolCallRequest:
21
+ id: str
22
+ name: str
23
+ args: Any = None
24
+
25
+
26
+ @dataclass
27
+ class ModelCompletion:
28
+ text: str = ""
29
+ thinking: str | None = None
30
+ tool_calls: list[ToolCallRequest] = field(default_factory=list)
31
+ usage: TokenUsage = field(default_factory=TokenUsage)
32
+ finish_reason: str | None = None
33
+
34
+
35
+ @dataclass
36
+ class ToolWire:
37
+ name: str
38
+ description: str
39
+ parameters_schema: dict[str, Any] | None = None
40
+
41
+
42
+ @dataclass
43
+ class ResponseFormatWire:
44
+ name: str
45
+ schema: dict[str, Any]
46
+ strict: bool | None = None
47
+
48
+
49
+ @dataclass
50
+ class CompleteOptions:
51
+ messages: list[ChatMessage]
52
+ tools: list[ToolWire] = field(default_factory=list)
53
+ response_format: ResponseFormatWire | None = None
54
+ on_token: Callable[[str], None] | None = None
55
+ on_thinking: Callable[[str], None] | None = None
56
+ signal: Any | None = None # httpx-compatible abort (client-side done in provider)
57
+
58
+
59
+ @runtime_checkable
60
+ class ModelProvider(Protocol):
61
+ id: str
62
+ capabilities: dict[str, bool]
63
+
64
+ async def complete(self, options: CompleteOptions) -> ModelCompletion: ...
65
+
66
+
67
+ class MockModelProvider:
68
+ """Deterministic mock for tests/examples — no network."""
69
+
70
+ id: str = "mock"
71
+ capabilities = {"structured_output": False, "streaming": False}
72
+
73
+ def __init__(self) -> None:
74
+ self._calls = 0
75
+ self._seq = itertools.count(1)
76
+
77
+ async def complete(self, options: CompleteOptions) -> ModelCompletion:
78
+ self._calls += 1
79
+ text = f"mock-response-{next(self._seq)}"
80
+ options.on_token and options.on_token(text)
81
+ return ModelCompletion(
82
+ text=text,
83
+ tool_calls=[],
84
+ usage=TokenUsage(input_tokens=10, output_tokens=5),
85
+ )
86
+
87
+
88
+ class ScriptedModelProvider:
89
+ """Test stub: returns canned completions in order (last one repeats)."""
90
+
91
+ id: str = "scripted"
92
+ capabilities = {"structured_output": False, "streaming": False}
93
+
94
+ def __init__(self, completions: list[ModelCompletion]) -> None:
95
+ self._completions = completions
96
+ self._n = 0
97
+
98
+ async def complete(self, options: CompleteOptions) -> ModelCompletion:
99
+ self._n += 1
100
+ return self._completions[min(self._n - 1, len(self._completions) - 1)]
@@ -0,0 +1,339 @@
1
+ """Native Anthropic Messages API provider (httpx-based, no SDK).
2
+
3
+ Parity: packages/typescript/src/models/anthropic.ts (post-sweep semantics):
4
+
5
+ - system messages extracted into the top-level `system` field
6
+ - ALL tool results from one assistant tool_use turn arrive in a SINGLE user
7
+ message (grouping consecutive tool results — parallel calls stay well-formed)
8
+ - reasoning blocks are DROPPED when re-sending history (wire_text_of)
9
+ - constructor FAILS LOUDLY when max_tokens <= thinking_budget (API 400 otherwise)
10
+ - streaming: the `event:` state CARRIES across chunk boundaries (a data line
11
+ landing in a different chunk than its event line must not parse as "") —
12
+ the TS regression fixed live — and usage arrives via message_start /
13
+ message_delta (cumulative output_tokens: assign, never add)
14
+ - stop_reason normalized (max_tokens -> length); a max_tokens stop with ZERO
15
+ usable content raises a loud MODEL_ERROR, and partial content keeps going
16
+ - tool schemas ride `input_schema`; signals accepted for abort.
17
+
18
+ No personal/brand names in code beyond the wire protocol constants the API
19
+ itself requires (header names, paths).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import itertools
25
+ import json
26
+ from dataclasses import dataclass
27
+ from typing import Any, Callable
28
+
29
+ import httpx
30
+
31
+ from ..errors import AgentError
32
+ from ..types import ChatMessage, TokenUsage, has_images, wire_text_of
33
+ from . import CompleteOptions, ModelCompletion, ModelProvider, ToolCallRequest
34
+ from .capabilities import assert_can_handle
35
+
36
+ _CALL_SEQ = itertools.count(1)
37
+
38
+
39
+ @dataclass
40
+ class AnthropicOptions:
41
+ api_key: str
42
+ model: str
43
+ base_url: str | None = None
44
+ max_tokens: int = 1024
45
+ temperature: float | None = None
46
+ thinking_budget: int | None = None
47
+ stream: bool = False
48
+ fetch_fn: Callable[[], Any] | None = None # httpx client factory for tests
49
+
50
+
51
+ class AnthropicProvider:
52
+ id: str
53
+ capabilities = {"structured_output": False, "streaming": True}
54
+
55
+ def __init__(self, opts: AnthropicOptions) -> None:
56
+ if not opts.api_key:
57
+ raise ValueError("AnthropicProvider: apiKey is required (.env ANTHROPIC_API_KEY)")
58
+ self.opts = AnthropicOptions(
59
+ base_url=opts.base_url or "https://api.anthropic.com",
60
+ api_key=opts.api_key,
61
+ model=opts.model,
62
+ max_tokens=opts.max_tokens,
63
+ temperature=opts.temperature,
64
+ thinking_budget=opts.thinking_budget,
65
+ stream=opts.stream,
66
+ fetch_fn=opts.fetch_fn,
67
+ )
68
+ self.id = f"anthropic:{opts.model}"
69
+ # Extended thinking requires max_tokens > budget_tokens — fail at
70
+ # construction with the fix, not as a confusing API 400 on first call.
71
+ if self.opts.thinking_budget is not None and self.opts.max_tokens <= self.opts.thinking_budget:
72
+ raise ValueError(
73
+ f"AnthropicProvider: max_tokens ({self.opts.max_tokens}) must exceed "
74
+ f"thinking_budget ({self.opts.thinking_budget}); raise max_tokens"
75
+ )
76
+
77
+ def _content(self, message: ChatMessage) -> Any:
78
+ if isinstance(message.content, str):
79
+ return message.content
80
+ parts: list[dict[str, Any]] = []
81
+ text = wire_text_of(message.content)
82
+ if text:
83
+ parts.append({"type": "text", "text": text})
84
+ for block in message.blocks():
85
+ kind = block.model_dump()
86
+ if kind["type"] == "image":
87
+ data = kind.get("data")
88
+ if data:
89
+ parts.append({"type": "image", "source": {
90
+ "type": "base64",
91
+ "media_type": kind.get("mimeType") or "image/jpeg",
92
+ "data": data,
93
+ }})
94
+ elif kind.get("url"):
95
+ parts.append({"type": "image", "source": {"type": "url", "url": kind["url"]}})
96
+ elif kind["type"] == "tool_call":
97
+ parts.append({"type": "tool_use", "id": kind["id"], "name": kind["name"], "input": kind.get("args") or {}})
98
+ return parts
99
+
100
+ def _messages(self, all_messages: list[ChatMessage]) -> list[dict[str, Any]]:
101
+ out: list[dict[str, Any]] = []
102
+ for message in all_messages:
103
+ if message.role == "system":
104
+ continue
105
+ if message.role == "tool":
106
+ # ALL tool_results for one tool_use turn belong in a SINGLE
107
+ # user message; group consecutive tool results.
108
+ result = {
109
+ "type": "tool_result",
110
+ "tool_use_id": message.tool_call_id,
111
+ "content": self._content(message),
112
+ }
113
+ if out and out[-1]["role"] == "user" and isinstance(out[-1]["content"], list) \
114
+ and any(b.get("type") == "tool_result" for b in out[-1]["content"]):
115
+ out[-1]["content"].append(result)
116
+ continue
117
+ out.append({"role": "user", "content": [result]})
118
+ continue
119
+ out.append({
120
+ "role": "assistant" if message.role == "assistant" else "user",
121
+ "content": self._content(message),
122
+ })
123
+ return out
124
+
125
+ async def complete(self, options: CompleteOptions) -> ModelCompletion:
126
+ opts = self.opts
127
+ # Images: fail fast with MODEL_CAPABILITY when the model can't see.
128
+ assert_can_handle(opts.model, any(has_images(m.content) for m in options.messages))
129
+ body: dict[str, Any] = {
130
+ "model": opts.model,
131
+ "max_tokens": opts.max_tokens,
132
+ "messages": self._messages(options.messages),
133
+ }
134
+ system = "\n\n".join(
135
+ wire_text_of(m.content) for m in options.messages if m.role == "system"
136
+ )
137
+ if system:
138
+ body["system"] = system
139
+ if opts.temperature is not None:
140
+ body["temperature"] = opts.temperature
141
+ if opts.thinking_budget is not None:
142
+ body["thinking"] = {"type": "enabled", "budget_tokens": opts.thinking_budget}
143
+ if opts.stream:
144
+ body["stream"] = True
145
+ if options.tools:
146
+ body["tools"] = [
147
+ {
148
+ "name": t.name,
149
+ "description": t.description,
150
+ "input_schema": t.parameters_schema or {"type": "object"},
151
+ }
152
+ for t in options.tools
153
+ ]
154
+
155
+ headers = {
156
+ "content-type": "application/json",
157
+ "x-api-key": opts.api_key,
158
+ "anthropic-version": "2023-06-01",
159
+ **({"accept": "text/event-stream"} if opts.stream else {}),
160
+ }
161
+ client = opts.fetch_fn() if opts.fetch_fn else httpx.AsyncClient(timeout=120.0)
162
+ try:
163
+ res = await client.request(
164
+ "POST",
165
+ f"{(opts.base_url or 'https://api.anthropic.com').rstrip('/')}/v1/messages",
166
+ headers=headers,
167
+ json=body,
168
+ )
169
+ except Exception as err:
170
+ raise AgentError("MODEL_ERROR", f"Model request failed: {err}", retryable=True) from err
171
+ finally:
172
+ if opts.fetch_fn is None:
173
+ await client.aclose()
174
+
175
+ if res.status_code == 429 or res.status_code >= 500:
176
+ raise AgentError("MODEL_ERROR", f"Model HTTP {res.status_code}: {res.text[:300]}", retryable=True)
177
+ if res.status_code >= 400:
178
+ raise AgentError("MODEL_ERROR", f"Model HTTP {res.status_code}: {res.text[:300]}", retryable=False)
179
+
180
+ if opts.stream:
181
+ return await self._read_stream(res, options)
182
+ return self._read_json(res, options)
183
+
184
+ def _read_json(self, res: Any, options: CompleteOptions) -> ModelCompletion:
185
+ data = res.json()
186
+ text = ""
187
+ thinking = ""
188
+ tool_calls: list[ToolCallRequest] = []
189
+ for block in data.get("content") or []:
190
+ if block.get("type") == "text" and block.get("text"):
191
+ text += block["text"]
192
+ options.on_token and options.on_token(block["text"])
193
+ elif block.get("type") == "thinking" and block.get("thinking"):
194
+ thinking += block["thinking"]
195
+ options.on_thinking and options.on_thinking(block["thinking"])
196
+ elif block.get("type") == "tool_use" and block.get("name"):
197
+ tool_calls.append(ToolCallRequest(
198
+ id=block.get("id") or f"call_{next(_CALL_SEQ)}",
199
+ name=block["name"],
200
+ args=block.get("input") or {},
201
+ ))
202
+ normalized = _normalize_stop(data.get("stop_reason"))
203
+ if not text and not tool_calls and thinking and normalized == "stop":
204
+ # Empty text + non-empty thinking + stop: the reasoning/content
205
+ # split put the WHOLE answer in the thinking channel. Never
206
+ # return an empty answer for a thinking model's short reply.
207
+ raise AgentError(
208
+ "MODEL_ERROR",
209
+ "Model stopped with stop_reason=stop having put the entire answer in "
210
+ "the reasoning channel (empty content, no tool calls, thinking present). "
211
+ "The reasoning/content split lost the reply — retry or raise max_tokens.",
212
+ retryable=True,
213
+ )
214
+ if not text and not tool_calls and normalized == "length":
215
+ raise AgentError(
216
+ "MODEL_ERROR",
217
+ f"Model stopped at max_tokens without producing content. Raise max_tokens.",
218
+ retryable=False,
219
+ )
220
+ usage = data.get("usage") or {}
221
+ return ModelCompletion(
222
+ text=text,
223
+ thinking=thinking or None,
224
+ tool_calls=tool_calls,
225
+ finish_reason=normalized,
226
+ usage=TokenUsage(input_tokens=usage.get("input_tokens", 0), output_tokens=usage.get("output_tokens", 0)),
227
+ )
228
+
229
+ async def _read_stream(self, res: Any, options: CompleteOptions) -> ModelCompletion:
230
+ text = ""
231
+ thinking = ""
232
+ input_tokens = 0
233
+ output_tokens = 0
234
+ stop_reason: str | None = None
235
+ blocks: list[dict[str, Any]] = []
236
+ current: dict[str, Any] | None = None
237
+ json_input = ""
238
+ buffer = ""
239
+ event = ""
240
+ async for chunk in res.aiter_text():
241
+ buffer += chunk
242
+ lines = buffer.split("\n")
243
+ buffer = lines.pop()
244
+ for line in lines:
245
+ stripped = line.strip()
246
+ if not stripped:
247
+ continue
248
+ if stripped.startswith("event:"):
249
+ # CRITICAL: the event name must carry across chunk
250
+ # boundaries — re-initializing per chunk silently drops
251
+ # every delta whose event line landed in a prior packet.
252
+ event = stripped[6:].strip()
253
+ continue
254
+ if not stripped.startswith("data:"):
255
+ continue
256
+ payload = stripped[5:].strip()
257
+ if not payload:
258
+ continue
259
+ try:
260
+ data = json.loads(payload)
261
+ except ValueError:
262
+ continue
263
+ if event == "message_start":
264
+ # message_start carries the input-side usage up front.
265
+ message = data.get("message") or {}
266
+ usage = message.get("usage") or {}
267
+ if usage.get("input_tokens") is not None:
268
+ input_tokens = usage["input_tokens"]
269
+ elif event == "message_delta":
270
+ # message_delta.usage.output_tokens is cumulative — assign.
271
+ usage = data.get("usage") or {}
272
+ if usage.get("output_tokens") is not None:
273
+ output_tokens = usage["output_tokens"]
274
+ delta = data.get("delta") or {}
275
+ if delta.get("stop_reason"):
276
+ stop_reason = _normalize_stop(delta["stop_reason"])
277
+ elif event == "content_block_start":
278
+ block = data.get("content_block") or {}
279
+ current = {"type": block.get("type"), "id": block.get("id"), "name": block.get("name")}
280
+ json_input = ""
281
+ elif event == "content_block_delta":
282
+ delta = data.get("delta") or {}
283
+ if delta.get("text"):
284
+ text += delta["text"]
285
+ current = current or {"type": "text"}
286
+ current["text"] = (current.get("text") or "") + delta["text"]
287
+ options.on_token and options.on_token(delta["text"])
288
+ elif delta.get("thinking"):
289
+ thinking += delta["thinking"]
290
+ current = current or {"type": "thinking"}
291
+ current["thinking"] = (current.get("thinking") or "") + delta["thinking"]
292
+ options.on_thinking and options.on_thinking(delta["thinking"])
293
+ elif delta.get("partial_json"):
294
+ json_input += delta["partial_json"]
295
+ elif event == "content_block_stop":
296
+ if current is not None:
297
+ if current.get("type") == "tool_use":
298
+ try:
299
+ current["input"] = json.loads(json_input or "{}")
300
+ except ValueError:
301
+ current["input"] = {}
302
+ blocks.append(current)
303
+ current = None
304
+ if current is not None:
305
+ blocks.append(current)
306
+
307
+ tool_calls = [
308
+ ToolCallRequest(id=b.get("id") or f"call_{next(_CALL_SEQ)}", name=b["name"], args=b.get("input") or {})
309
+ for b in blocks if b.get("type") == "tool_use" and b.get("name")
310
+ ]
311
+ normalized = stop_reason
312
+ if not text and not tool_calls and thinking and normalized == "stop":
313
+ # Stream mirror of the readJson reasoning-split guard (above).
314
+ raise AgentError(
315
+ "MODEL_ERROR",
316
+ "Stream ended at stop_reason=stop having put the entire answer in "
317
+ "the reasoning channel (no content, no tool calls, thinking present). "
318
+ "The reasoning/content split lost the reply — retry or raise max_tokens.",
319
+ retryable=True,
320
+ )
321
+ if not text and not tool_calls and normalized == "length":
322
+ raise AgentError(
323
+ "MODEL_ERROR",
324
+ "Stream stopped at max_tokens without producing content. Raise max_tokens.",
325
+ retryable=False,
326
+ )
327
+ return ModelCompletion(
328
+ text=text,
329
+ thinking=thinking or None,
330
+ tool_calls=tool_calls,
331
+ finish_reason=normalized,
332
+ usage=TokenUsage(input_tokens=input_tokens, output_tokens=output_tokens),
333
+ )
334
+
335
+
336
+ def _normalize_stop(stop: str | None) -> str | None:
337
+ if not stop:
338
+ return None
339
+ return "length" if stop == "max_tokens" else stop
@@ -0,0 +1,45 @@
1
+ """Model capability registry. Parity: src/models/capabilities.ts.
2
+
3
+ Researched defaults live here so users never pick tiers. Full per-model
4
+ research (vision, tools, reasoning, context limits) lands later; until then:
5
+ explicit allowlist + fail-fast error.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ from ..errors import AgentError
13
+
14
+ # Model ids known to accept image inputs (researched 2026-10; extended as
15
+ # models are verified). Mirrors the TS regex table exactly.
16
+ VISION_CAPABLE_PATTERNS: tuple[str, ...] = (
17
+ r"qwen2.*vl",
18
+ r"qwen.*vl",
19
+ r"llama-3\.2-11b-vision",
20
+ r"llama-3\.2-90b-vision",
21
+ r"llava",
22
+ r"minicpm-v",
23
+ r"gpt-4o",
24
+ r"gpt-4\.1",
25
+ r"claude.*sonnet",
26
+ r"claude.*opus",
27
+ r"gemini",
28
+ )
29
+
30
+ _COMPILED = tuple(re.compile(pattern, re.IGNORECASE) for pattern in VISION_CAPABLE_PATTERNS)
31
+
32
+
33
+ def supports_vision(model_id: str) -> bool:
34
+ return any(pattern.search(model_id) for pattern in _COMPILED)
35
+
36
+
37
+ def assert_can_handle(model_id: str, needs_vision: bool) -> None:
38
+ """Raise AgentError(MODEL_CAPABILITY) when input needs vision the model lacks."""
39
+ if needs_vision and not supports_vision(model_id):
40
+ raise AgentError(
41
+ "MODEL_CAPABILITY",
42
+ f'Model "{model_id}" cannot take image input. Switch TOGETHER_MODEL to a '
43
+ "vision-capable model (e.g. Qwen/Qwen2.5-VL-72B-Instruct) or drop the images.",
44
+ retryable=False,
45
+ )
@@ -0,0 +1,47 @@
1
+ """Local-first Ollama provider. Parity: src/models/ollama.ts.
2
+
3
+ Via Ollama's OpenAI-compatible endpoint: no API key, no account, no network
4
+ beyond localhost. Tool support depends on the model (qwen2.5, llama3.1+ work
5
+ well). structured_output stays False -> `auto` format uses the tool strategy.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Any, Callable, Optional
12
+
13
+ from .openai_compatible import OpenAICompatibleOptions, OpenAICompatibleProvider
14
+
15
+
16
+ @dataclass
17
+ class OllamaOptions:
18
+ model: str
19
+ # Defaults to local Ollama. No key needed.
20
+ base_url: Optional[str] = None
21
+ temperature: Optional[float] = None
22
+ max_tokens: Optional[int] = None
23
+ stream: Optional[bool] = None
24
+ fetch_fn: Optional[Callable[[], Any]] = None
25
+
26
+
27
+ class OllamaProvider(OpenAICompatibleProvider):
28
+ id: str
29
+ capabilities = {"structured_output": False, "streaming": True}
30
+
31
+ def __init__(self, opts: OllamaOptions) -> None:
32
+ super().__init__(
33
+ OpenAICompatibleOptions(
34
+ base_url=opts.base_url or "http://localhost:11434/v1",
35
+ api_key="ollama",
36
+ model=opts.model,
37
+ temperature=opts.temperature if opts.temperature is not None else 0.2,
38
+ max_tokens=opts.max_tokens,
39
+ stream=bool(opts.stream),
40
+ fetch_fn=opts.fetch_fn,
41
+ )
42
+ )
43
+ self.id = f"ollama:{opts.model}"
44
+
45
+
46
+ def ollama(model: str, **kwargs: Any) -> OllamaProvider:
47
+ return OllamaProvider(OllamaOptions(model=model, **kwargs))