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,409 @@
1
+ """OpenAI-compatible chat provider (TogetherAI/OpenAI/vLLM), httpx-based.
2
+
3
+ Ports the live-verified semantics from the TS provider:
4
+ - UNION field parsing: Together sends `message.reasoning` + top-level
5
+ `usage.reasoning_tokens`; OpenAI/vLLM send `reasoning_content` +
6
+ `completion_tokens_details.reasoning_tokens`. Both shapes work.
7
+ - Streaming: `stream_options: include_usage` requested; `"usage": null`
8
+ content chunks skipped; usage-only final chunk parsed.
9
+ - Silent-truncation guard: finish_reason="length" with ZERO content and no
10
+ tool calls raises a LOUD MODEL_ERROR (raise max_tokens) — never a silent
11
+ empty answer. Partial content keeps going, with finish_reason attached.
12
+ - Tool parameter schemas go on the wire (`function.parameters`).
13
+ - Streaming tool-call ids never repeat across turns (call_<seq>).
14
+ - abort + HTTP error mapping: network/429/5xx retryable, others not.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import itertools
21
+ import json
22
+ from dataclasses import dataclass
23
+ from typing import Any, Callable
24
+
25
+ import httpx
26
+ from pydantic import BaseModel
27
+
28
+ from ..errors import AgentError
29
+ from ..types import ChatMessage, ImageBlock, TokenUsage, ToolCallBlock, has_images, wire_text_of
30
+ from . import CompleteOptions, ModelCompletion, ModelProvider, ToolCallRequest, ToolWire
31
+ from .capabilities import assert_can_handle
32
+
33
+ _CALL_SEQ = itertools.count(1)
34
+
35
+
36
+ class _Abort(Exception):
37
+ """Internal: the caller's signal fired mid-request."""
38
+
39
+
40
+ async def _respect_signal(signal: Any) -> None:
41
+ """No-op unless the caller passed an abort Event; wait-free when None."""
42
+ if signal is None:
43
+ return
44
+ try:
45
+ already = signal.is_set()
46
+ except AttributeError:
47
+ return # foreign signal types are ignored (documented)
48
+ if already:
49
+ raise _Abort()
50
+
51
+
52
+ @dataclass
53
+ class OpenAICompatibleOptions:
54
+ base_url: str
55
+ api_key: str
56
+ model: str
57
+ temperature: float = 0.2
58
+ max_tokens: int | None = None
59
+ stream: bool = False
60
+ fetch_fn: Callable[..., Any] | None = None # httpx-client factory for tests
61
+
62
+
63
+ class OpenAICompatibleProvider:
64
+ id: str
65
+ capabilities = {"structured_output": True, "streaming": True}
66
+
67
+ def __init__(self, opts: OpenAICompatibleOptions) -> None:
68
+ if not opts.api_key:
69
+ raise ValueError("OpenAICompatibleProvider: apiKey is required")
70
+ self.id = f"openai-compatible:{opts.model}"
71
+ self.opts = opts
72
+
73
+ # -- wire conversion ---------------------------------------------------
74
+
75
+ def _wire_content(self, message: ChatMessage, blocks: list[BaseModel]) -> Any:
76
+ if isinstance(message.content, str):
77
+ return message.content
78
+ text = wire_text_of(message.content)
79
+ images = [b for b in blocks if isinstance(b, ImageBlock)]
80
+ if not images:
81
+ return text
82
+ parts: list[dict[str, Any]] = ([{"type": "text", "text": text}] if text else [])
83
+ for img in images:
84
+ data = getattr(img, "data", None)
85
+ mime = getattr(img, "mime_type", None) or "image/jpeg"
86
+ url = f"data:{mime};base64,{data}" if data else getattr(img, "url", None)
87
+ parts.append({"type": "image_url", "image_url": {"url": url}})
88
+ return parts
89
+
90
+ def _wire_message(self, message: ChatMessage) -> dict[str, Any]:
91
+ # blocks() is parsed ONCE per message (memoized at the types layer)
92
+ # and reused for both the content and the tool_calls halves.
93
+ blocks: list[BaseModel] = [] if isinstance(message.content, str) else message.blocks()
94
+ base: dict[str, Any] = {
95
+ "role": message.role,
96
+ "content": self._wire_content(message, blocks),
97
+ }
98
+ if message.tool_call_id:
99
+ base["tool_call_id"] = message.tool_call_id
100
+ if blocks:
101
+ calls = [b for b in blocks if isinstance(b, ToolCallBlock)]
102
+ if calls:
103
+ base["tool_calls"] = [
104
+ {
105
+ "id": c.id,
106
+ "type": "function",
107
+ "function": {"name": c.name, "arguments": json.dumps(c.args or {})},
108
+ }
109
+ for c in calls
110
+ ]
111
+ return base
112
+
113
+ # -- request -----------------------------------------------------------
114
+
115
+ async def complete(self, options: CompleteOptions) -> ModelCompletion:
116
+ opts = self.opts
117
+ # Images: fail fast with MODEL_CAPABILITY when the model can't see.
118
+ assert_can_handle(opts.model, any(has_images(m.content) for m in options.messages))
119
+ tools = options.tools
120
+ body: dict[str, Any] = {
121
+ "model": opts.model,
122
+ "temperature": opts.temperature,
123
+ "messages": [self._wire_message(m) for m in options.messages],
124
+ **({"max_tokens": opts.max_tokens} if opts.max_tokens else {}),
125
+ # stream_options makes OpenAI-compatible servers attach usage to
126
+ # the final chunk; without it streaming runs report 0 tokens.
127
+ **(
128
+ {"stream": True, "stream_options": {"include_usage": True}}
129
+ if opts.stream
130
+ else {}
131
+ ),
132
+ }
133
+ if options.response_format:
134
+ body["response_format"] = {
135
+ "type": "json_schema",
136
+ "json_schema": {
137
+ "name": options.response_format.name,
138
+ "strict": options.response_format.strict if options.response_format.strict is not None else True,
139
+ "schema": options.response_format.schema,
140
+ },
141
+ }
142
+ if tools:
143
+ body["tools"] = [
144
+ {
145
+ "type": "function",
146
+ "function": {
147
+ "name": t.name,
148
+ "description": t.description,
149
+ # Without parameters the API advertises a zero-arg tool.
150
+ **({"parameters": t.parameters_schema} if t.parameters_schema else {}),
151
+ },
152
+ }
153
+ for t in tools
154
+ ]
155
+
156
+ client = opts.fetch_fn() if opts.fetch_fn else httpx.AsyncClient(timeout=120.0)
157
+ url = f"{opts.base_url.rstrip('/')}/chat/completions"
158
+ headers = {"content-type": "application/json", "authorization": f"Bearer {opts.api_key}"}
159
+ try:
160
+ # Stream=True NEVER lets httpx buffer the whole body first: the
161
+ # request must consume incrementally so on_token/on_thinking fire
162
+ # live and abort can actually interrupt the upload/response.
163
+ # Duck-typed test clients exposing only .request() fall back
164
+ # (they already return a fully-formed response object).
165
+ if hasattr(client, "build_request"):
166
+ request = client.build_request("POST", url, headers=headers, json=body)
167
+ res = await client.send(request, stream=opts.stream)
168
+ else:
169
+ res = await client.request("POST", url, headers=headers, json=body)
170
+
171
+ # Abort actually CANCELS the in-flight request (the old code took
172
+ # options.signal and ignored it — abort ran to completion and burned
173
+ # tokens). Race the response wait against the caller's signal.
174
+ await _respect_signal(options.signal)
175
+
176
+ if res.status_code == 429 or res.status_code >= 500:
177
+ text = (await res.aread()).decode("utf-8", "replace") if opts.stream else res.text
178
+ raise AgentError("MODEL_ERROR", f"Model HTTP {res.status_code}: {text[:300]}", retryable=True)
179
+ if res.status_code >= 400:
180
+ text = (await res.aread()).decode("utf-8", "replace") if opts.stream else res.text
181
+ raise AgentError("MODEL_ERROR", f"Model HTTP {res.status_code}: {text[:300]}", retryable=False)
182
+
183
+ if opts.stream:
184
+ try:
185
+ # Live consumption — the client stays open until the stream ends.
186
+ return await self._read_stream(res, options)
187
+ finally:
188
+ await res.aclose()
189
+ return self._read_json(res, options)
190
+ except _Abort:
191
+ raise AgentError("ABORTED", "Run aborted", retryable=False)
192
+ except Exception as err:
193
+ if isinstance(err, AgentError):
194
+ raise
195
+ raise AgentError("MODEL_ERROR", f"Model request failed: {err}", retryable=True) from err
196
+ finally:
197
+ if opts.fetch_fn is None:
198
+ await client.aclose()
199
+
200
+ def _read_json(self, res: Any, options: CompleteOptions) -> ModelCompletion:
201
+ data = res.json()
202
+ choices = data.get("choices") or [{}]
203
+ choice = choices[0] or {}
204
+ message = choice.get("message") or {}
205
+ thinking = _thinking_of(message)
206
+ if thinking:
207
+ options.on_thinking and options.on_thinking(thinking)
208
+ content = message.get("content") or ""
209
+ if content:
210
+ options.on_token and options.on_token(content)
211
+ tool_calls = [
212
+ ToolCallRequest(
213
+ id=(c.get("id") or f"call_{next(_CALL_SEQ)}"),
214
+ name=(c.get("function") or {}).get("name", ""),
215
+ args=_safe_json((c.get("function") or {}).get("arguments", "{}")),
216
+ )
217
+ for c in (message.get("tool_calls") or [])
218
+ ]
219
+ usage = data.get("usage") or {}
220
+ finish_reason = choice.get("finish_reason") or None
221
+ reasoning = _reasoning_of(usage)
222
+ if not content and not tool_calls and thinking and finish_reason == "stop":
223
+ # Empty content + non-empty thinking + stop: the reasoning/content
224
+ # split put the WHOLE answer in the reasoning channel (thinking
225
+ # models do this on short replies). Never return an empty answer.
226
+ raise AgentError(
227
+ "MODEL_ERROR",
228
+ "Model stopped with finish_reason=stop having put the entire answer in "
229
+ "the reasoning channel (empty content, no tool calls, thinking present). "
230
+ "The reasoning/content split lost the reply — retry or raise maxTokens.",
231
+ retryable=True,
232
+ )
233
+ if not content and not tool_calls and finish_reason == "length":
234
+ # A thinking model can burn its WHOLE max_tokens before any
235
+ # content: never let that come back as a successful empty reply.
236
+ raise AgentError(
237
+ "MODEL_ERROR",
238
+ f"Model spent its entire max_tokens budget ({usage.get('completion_tokens', '?')} tokens, "
239
+ "finish_reason=length) thinking without producing content. Raise maxTokens.",
240
+ retryable=False,
241
+ )
242
+ return ModelCompletion(
243
+ text=content,
244
+ thinking=thinking,
245
+ tool_calls=tool_calls,
246
+ finish_reason=finish_reason,
247
+ usage=TokenUsage(
248
+ input_tokens=usage.get("prompt_tokens", 0),
249
+ output_tokens=usage.get("completion_tokens", 0),
250
+ reasoning_tokens=reasoning,
251
+ ),
252
+ )
253
+
254
+ async def _read_stream(self, res: Any, options: CompleteOptions) -> ModelCompletion:
255
+ text = ""
256
+ thinking = ""
257
+ input_tokens = 0
258
+ output_tokens = 0
259
+ reasoning_tokens: int | None = None
260
+ finish_reason: str | None = None
261
+ tool_buffers: dict[int, dict[str, str]] = {}
262
+
263
+ # A line can SPLIT across TCP reads: the first fragment would fail
264
+ # json.loads and the continuation would not start with "data:" — both
265
+ # silently dropped. Accumulate a buffer and only parse COMPLETE
266
+ # lines (TS parity: buffer += chunk; lines.pop()).
267
+ buffer = ""
268
+
269
+ # Mid-STREAM abort: closing the response interrupts aiter_text, the
270
+ # same way TS aborts via the fetch signal.
271
+ watchdog: "asyncio.Task[None] | None" = None
272
+ if options.signal is not None:
273
+ async def _watch() -> None:
274
+ await options.signal.wait() # type: ignore[union-attr]
275
+ await res.aclose()
276
+
277
+ watchdog = asyncio.create_task(_watch())
278
+
279
+ try:
280
+ async for chunk in res.aiter_text():
281
+ buffer += chunk
282
+ lines = buffer.split("\n")
283
+ buffer = lines.pop()
284
+ for line in lines:
285
+ line = line.strip()
286
+ if not line.startswith("data:"):
287
+ continue
288
+ payload = line[5:].strip()
289
+ if payload == "[DONE]":
290
+ continue
291
+ try:
292
+ evt = json.loads(payload)
293
+ except ValueError:
294
+ continue
295
+ first = (evt.get("choices") or [{}])[0] or {}
296
+ if first.get("finish_reason"):
297
+ finish_reason = first["finish_reason"]
298
+ usage = evt.get("usage")
299
+ # Together sends "usage": null on content chunks; the final
300
+ # usage-only chunk has an empty/partial choices array.
301
+ if usage:
302
+ input_tokens = usage.get("prompt_tokens", input_tokens)
303
+ output_tokens = usage.get("completion_tokens", output_tokens)
304
+ reasoning_tokens = _reasoning_of(usage)
305
+ continue
306
+ delta = first.get("delta") or {}
307
+ think = _thinking_of(delta)
308
+ if think:
309
+ thinking += think
310
+ options.on_thinking and options.on_thinking(think)
311
+ if delta.get("content"):
312
+ text += delta["content"]
313
+ options.on_token and options.on_token(delta["content"])
314
+ for i, tc in enumerate(delta.get("tool_calls") or []):
315
+ key = tc.get("index", i)
316
+ slot = tool_buffers.setdefault(key, {"id": "", "name": "", "args": ""})
317
+ if tc.get("id"):
318
+ slot["id"] = tc["id"]
319
+ fn = tc.get("function") or {}
320
+ if fn.get("name"):
321
+ slot["name"] = fn["name"]
322
+ if fn.get("arguments"):
323
+ slot["args"] += fn["arguments"]
324
+ finally:
325
+ if watchdog is not None:
326
+ watchdog.cancel()
327
+
328
+ if not text and not tool_buffers and thinking and finish_reason == "stop":
329
+ # Stream mirror of the readJson reasoning-split guard (above).
330
+ raise AgentError(
331
+ "MODEL_ERROR",
332
+ "Stream ended at finish_reason=stop having put the entire answer in "
333
+ "the reasoning channel (no content, no tool calls, thinking present). "
334
+ "The reasoning/content split lost the reply — retry or raise maxTokens.",
335
+ retryable=True,
336
+ )
337
+ if not text and not tool_buffers and finish_reason == "length":
338
+ raise AgentError(
339
+ "MODEL_ERROR",
340
+ f"Stream ended at finish_reason=length with no content ({output_tokens} tokens burned). Raise maxTokens.",
341
+ retryable=False,
342
+ )
343
+ tool_calls = [
344
+ ToolCallRequest(
345
+ id=(t["id"] or f"call_{next(_CALL_SEQ)}"),
346
+ name=t["name"],
347
+ args=_safe_json(t["args"]),
348
+ )
349
+ for t in tool_buffers.values()
350
+ if t["name"]
351
+ ]
352
+ return ModelCompletion(
353
+ text=text,
354
+ thinking=thinking or None,
355
+ tool_calls=tool_calls,
356
+ finish_reason=finish_reason,
357
+ usage=TokenUsage(
358
+ input_tokens=input_tokens,
359
+ output_tokens=output_tokens,
360
+ reasoning_tokens=reasoning_tokens,
361
+ ),
362
+ )
363
+
364
+
365
+ def _thinking_of(message: dict[str, Any]) -> str | None:
366
+ trace = message.get("reasoning") or message.get("reasoning_content")
367
+ return trace if trace else None
368
+
369
+
370
+ def _reasoning_of(usage: dict[str, Any]) -> int | None:
371
+ # Together reports top-level, OpenAI nests in completion_tokens_details.
372
+ value = usage.get("reasoning_tokens")
373
+ if value is None:
374
+ details = usage.get("completion_tokens_details") or {}
375
+ value = details.get("reasoning_tokens")
376
+ return value
377
+
378
+
379
+ def _safe_json(raw: str) -> Any:
380
+ try:
381
+ return json.loads(raw or "{}")
382
+ except ValueError:
383
+ return {}
384
+
385
+
386
+ def provider_from_env(
387
+ active: dict[str, str],
388
+ extra: dict[str, Any] | None = None,
389
+ ) -> OpenAICompatibleProvider:
390
+ """Build a provider from env config (TogetherAI preferred when key present).
391
+
392
+ TS naming mapped to the python options: ``active`` carries the camelCase
393
+ env keys (``baseUrl``/``apiKey``/``model``; snake_case accepted as a
394
+ fallback); ``extra`` is any extra OpenAICompatibleOptions kwargs
395
+ (temperature, maxTokens, stream, fetchFn).
396
+ """
397
+ base_url = active.get("baseUrl") or active.get("base_url")
398
+ api_key = active.get("apiKey") or active.get("api_key")
399
+ model = active.get("model")
400
+ if not base_url or not api_key or not model:
401
+ raise ValueError("providerFromEnv: active config requires baseUrl, apiKey and model")
402
+ extra_mapped = dict(extra or {})
403
+ if "maxTokens" in extra_mapped:
404
+ extra_mapped.setdefault("max_tokens", extra_mapped.pop("maxTokens"))
405
+ if "fetchFn" in extra_mapped:
406
+ extra_mapped.setdefault("fetch_fn", extra_mapped.pop("fetchFn"))
407
+ return OpenAICompatibleProvider(
408
+ OpenAICompatibleOptions(base_url=base_url, api_key=api_key, model=model, **extra_mapped)
409
+ )
@@ -0,0 +1,84 @@
1
+ """Console observability hooks. Parity: src/observability/index.ts.
2
+
3
+ AgentEvents-shaped console logging + a red error line. Colors only when
4
+ stdout is a TTY and NO_COLOR is unset (paint-inert otherwise). Hooks are
5
+ plain (async-safe) print calls: the agent loop awaits nothing here, so
6
+ they may be sync or async-friendly callables with no blocking work.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import sys
13
+ from typing import Any, Optional
14
+
15
+ from .errors import AgentError
16
+
17
+ _ANSI = {
18
+ "reset": "\x1b[0m",
19
+ "bold": "\x1b[1m",
20
+ "dim": "\x1b[2m",
21
+ "cyan": "\x1b[36m",
22
+ "yellow": "\x1b[33m",
23
+ "green": "\x1b[32m",
24
+ "red": "\x1b[31m",
25
+ }
26
+
27
+
28
+ def supports_color() -> bool:
29
+ """No NO_COLOR, and stdout is a TTY."""
30
+ if os.environ.get("NO_COLOR"):
31
+ return False
32
+ try:
33
+ return bool(sys.stdout.isatty())
34
+ except (AttributeError, ValueError): # pragma: no cover - exotic stdouts
35
+ return False
36
+
37
+
38
+ def _paint(color: str, text: str, on: bool) -> str:
39
+ if not on or color == "reset":
40
+ return text
41
+ return f"{_ANSI[color]}{text}{_ANSI['reset']}"
42
+
43
+
44
+ class ConsoleEvents:
45
+ """AgentEvents-shaped console logger (step/token/tool/done lines)."""
46
+
47
+ def __init__(self, prefix: str = "[agent]", colors: Optional[bool] = None) -> None:
48
+ self.prefix = prefix
49
+ self.colors = supports_color() if colors is None else bool(colors)
50
+
51
+ def _tag(self, color: str, label: str) -> str:
52
+ return _paint(color, f"{self.prefix} {label}", self.colors)
53
+
54
+ def on_step(self, step: int, messages: Any = None) -> None:
55
+ detail = f" ({len(messages)} msgs)" if messages is not None else ""
56
+ print(self._tag("cyan", f"step {step}{detail}"))
57
+
58
+ def on_tool_call(self, name: str, args: Any) -> None:
59
+ print(self._tag("yellow", f"tool:call {name}"), args)
60
+
61
+ def on_tool_result(self, name: str, output: Any) -> None:
62
+ print(self._tag("green", f"tool:result {name}"), output)
63
+
64
+ def on_done(self, text: str, tool_calls: Any = None) -> None:
65
+ print(self._tag("green", _paint("bold", "done:", self.colors)), text)
66
+
67
+ def on_token(self, delta: str) -> None:
68
+ print(delta, end="", flush=True)
69
+
70
+ def on_thinking(self, delta: str) -> None:
71
+ print(_paint("dim", delta, self.colors), end="", flush=True)
72
+
73
+
74
+ def console_events(prefix: str = "[agent]", colors: Optional[bool] = None) -> ConsoleEvents:
75
+ """AgentEvents-shaped console logger; `colors=None` => TTY without NO_COLOR."""
76
+ return ConsoleEvents(prefix=prefix, colors=colors)
77
+
78
+
79
+ def log_error(prefix: str, err: BaseException, colors: Optional[bool] = None) -> None:
80
+ """Red error line (pairs with console_events): code + message."""
81
+ on = supports_color() if colors is None else bool(colors)
82
+ code = err.code if isinstance(err, AgentError) else "ERROR"
83
+ message = str(err) or err.__class__.__name__
84
+ print(f"{_paint('red', f'{prefix} error:', on)} [{code}] {message}", file=sys.stderr)