readyagentsdev 0.8.2__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 (51) hide show
  1. readyagents/__init__.py +38 -0
  2. readyagents/__main__.py +4 -0
  3. readyagents/audit.py +67 -0
  4. readyagents/cli.py +1050 -0
  5. readyagents/config.py +264 -0
  6. readyagents/errors.py +129 -0
  7. readyagents/llm/__init__.py +11 -0
  8. readyagents/llm/anthropic_provider.py +72 -0
  9. readyagents/llm/base.py +57 -0
  10. readyagents/llm/cache.py +86 -0
  11. readyagents/llm/openai_compat.py +12 -0
  12. readyagents/llm/openai_provider.py +70 -0
  13. readyagents/llm/registry.py +112 -0
  14. readyagents/llm/resilience.py +179 -0
  15. readyagents/llm/tool_calls.py +286 -0
  16. readyagents/logging.py +162 -0
  17. readyagents/mcp/__init__.py +43 -0
  18. readyagents/mcp/builtin.py +674 -0
  19. readyagents/mcp/client.py +253 -0
  20. readyagents/mcp/http.py +585 -0
  21. readyagents/mcp/run_api.py +1077 -0
  22. readyagents/mcp/server.py +246 -0
  23. readyagents/notify.py +63 -0
  24. readyagents/packs/__init__.py +26 -0
  25. readyagents/packs/loader.py +157 -0
  26. readyagents/packs/protocol.py +55 -0
  27. readyagents/policy.py +127 -0
  28. readyagents/py.typed +1 -0
  29. readyagents/report.py +88 -0
  30. readyagents/scaffold.py +410 -0
  31. readyagents/secrets.py +120 -0
  32. readyagents/testing/__init__.py +17 -0
  33. readyagents/testing/eval.py +219 -0
  34. readyagents/testing/helpers.py +128 -0
  35. readyagents/testing/recorded.py +68 -0
  36. readyagents/tools/__init__.py +67 -0
  37. readyagents/workflow/__init__.py +3 -0
  38. readyagents/workflow/cancellation.py +88 -0
  39. readyagents/workflow/conditions.py +279 -0
  40. readyagents/workflow/engine.py +354 -0
  41. readyagents/workflow/nodes.py +944 -0
  42. readyagents/workflow/runner.py +375 -0
  43. readyagents/workflow/schema.py +287 -0
  44. readyagents/workflow/state.py +472 -0
  45. readyagents/workflow/structured.py +103 -0
  46. readyagents/workflow/templates.py +125 -0
  47. readyagentsdev-0.8.2.dist-info/METADATA +215 -0
  48. readyagentsdev-0.8.2.dist-info/RECORD +51 -0
  49. readyagentsdev-0.8.2.dist-info/WHEEL +4 -0
  50. readyagentsdev-0.8.2.dist-info/entry_points.txt +2 -0
  51. readyagentsdev-0.8.2.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,70 @@
1
+ """OpenAI Chat Completions provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from readyagents.errors import LLMError
8
+ from readyagents.llm.base import CompletionResult, Message
9
+ from readyagents.llm.tool_calls import (
10
+ messages_to_openai,
11
+ openai_tools_payload,
12
+ tool_calls_from_openai_message,
13
+ )
14
+
15
+
16
+ class OpenAIProvider:
17
+ name = "openai"
18
+
19
+ def __init__(self, api_key: str, *, base_url: str | None = None) -> None:
20
+ self._api_key = api_key
21
+ self._base_url = base_url
22
+
23
+ def complete(
24
+ self,
25
+ messages: list[Message],
26
+ *,
27
+ model: str,
28
+ tools: list[dict[str, Any]] | None = None,
29
+ **kwargs: Any,
30
+ ) -> CompletionResult:
31
+ try:
32
+ from openai import OpenAI
33
+ except ImportError as exc:
34
+ raise LLMError(
35
+ "The OpenAI extra is not installed. Run: pip install 'readyagents[openai]'"
36
+ ) from exc
37
+ try:
38
+ client_kwargs: dict[str, Any] = {"api_key": self._api_key}
39
+ if self._base_url:
40
+ client_kwargs["base_url"] = self._base_url
41
+ client = OpenAI(**client_kwargs)
42
+ payload: dict[str, Any] = {
43
+ "model": model,
44
+ "messages": messages_to_openai(messages),
45
+ }
46
+ openai_tools = openai_tools_payload(tools)
47
+ if openai_tools:
48
+ payload["tools"] = openai_tools
49
+ payload.update({k: v for k, v in kwargs.items() if v is not None})
50
+ response = client.chat.completions.create(**payload)
51
+ choice = response.choices[0]
52
+ text = (choice.message.content or "").strip()
53
+ usage: dict[str, Any] = {}
54
+ if response.usage:
55
+ usage = {
56
+ "prompt_tokens": response.usage.prompt_tokens,
57
+ "completion_tokens": response.usage.completion_tokens,
58
+ "total_tokens": response.usage.total_tokens,
59
+ }
60
+ return CompletionResult(
61
+ text=text,
62
+ model=model,
63
+ raw=response,
64
+ usage=usage,
65
+ tool_calls=tool_calls_from_openai_message(choice.message),
66
+ )
67
+ except LLMError:
68
+ raise
69
+ except Exception as exc: # noqa: BLE001
70
+ raise LLMError(f"OpenAI request failed: {exc}") from exc
@@ -0,0 +1,112 @@
1
+ """Resolve an LLM provider from settings / model ref."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from readyagents.config import Settings, get_settings, require_api_key
6
+ from readyagents.errors import LLMError
7
+ from readyagents.llm.anthropic_provider import AnthropicProvider
8
+ from readyagents.llm.base import LLMProvider, parse_model_ref
9
+ from readyagents.llm.openai_compat import OpenAICompatProvider
10
+ from readyagents.llm.openai_provider import OpenAIProvider
11
+ from readyagents.logging import get_logger
12
+
13
+ log = get_logger("llm")
14
+
15
+ _COMPAT_NAMES = {"openai-compat", "openai_compat", "compat", "groq", "ollama"}
16
+
17
+ # Documented defaults from .env.example / docs/configuration.md
18
+ _FALLBACK_MODELS = {
19
+ "openai": "gpt-4o-mini",
20
+ "anthropic": "claude-sonnet-4-5",
21
+ "openai-compat": "llama-3.1-8b-instant",
22
+ "groq": "llama-3.1-8b-instant",
23
+ "ollama": "llama3",
24
+ }
25
+
26
+
27
+ def _has_key(settings: Settings, provider_name: str, secrets: object = None) -> bool:
28
+ from readyagents.secrets import secret_for_provider
29
+
30
+ if provider_name == "openai":
31
+ name = "openai"
32
+ elif provider_name == "anthropic":
33
+ name = "anthropic"
34
+ elif provider_name in _COMPAT_NAMES:
35
+ name = "openai-compat"
36
+ else:
37
+ return False
38
+ return bool(secret_for_provider(name, settings=settings, secrets=secrets))
39
+
40
+
41
+ def _implicit_fallback_ref(settings: Settings, secrets: object = None) -> str | None:
42
+ """First provider that actually has a key. None if BYOK is empty."""
43
+ from readyagents.secrets import secret_for_provider
44
+
45
+ if secret_for_provider("openai", settings=settings, secrets=secrets):
46
+ return f"openai:{_FALLBACK_MODELS['openai']}"
47
+ if secret_for_provider("anthropic", settings=settings, secrets=secrets):
48
+ return f"anthropic:{_FALLBACK_MODELS['anthropic']}"
49
+ if secret_for_provider("openai-compat", settings=settings, secrets=secrets):
50
+ if settings.openai_compat_base_url:
51
+ return f"openai-compat:{_FALLBACK_MODELS['openai-compat']}"
52
+ return f"groq:{_FALLBACK_MODELS['groq']}"
53
+ return None
54
+
55
+
56
+ def get_provider(
57
+ model_ref: str | None = None,
58
+ *,
59
+ settings: Settings | None = None,
60
+ implicit: bool = False,
61
+ secrets: object = None,
62
+ ) -> tuple[LLMProvider, str]:
63
+ """Return `(provider, model_id)` for a `provider:model` string.
64
+
65
+ When ``implicit`` is true (agent node has no ``model:``), a missing key
66
+ for the default provider falls back to whichever BYOK key is set.
67
+ An explicit model ref never falls back.
68
+ """
69
+ settings = settings or get_settings()
70
+ ref = model_ref or settings.default_model
71
+ provider_name, model_id = parse_model_ref(ref)
72
+
73
+ if implicit and not _has_key(settings, provider_name, secrets):
74
+ fallback = _implicit_fallback_ref(settings, secrets)
75
+ if fallback:
76
+ new_provider, new_model = parse_model_ref(fallback)
77
+ log.info(
78
+ "No API key for default provider '%s'; using %s:%s",
79
+ provider_name,
80
+ new_provider,
81
+ new_model,
82
+ )
83
+ provider_name, model_id = new_provider, new_model
84
+
85
+ if provider_name == "openai":
86
+ key = require_api_key("openai", settings, secrets=secrets)
87
+ return OpenAIProvider(key), model_id
88
+
89
+ if provider_name == "anthropic":
90
+ key = require_api_key("anthropic", settings, secrets=secrets)
91
+ return AnthropicProvider(key), model_id
92
+
93
+ if provider_name in _COMPAT_NAMES:
94
+ key = require_api_key("openai-compat", settings, secrets=secrets)
95
+ base = settings.openai_compat_base_url
96
+ if not base:
97
+ if provider_name == "groq":
98
+ base = "https://api.groq.com/openai/v1"
99
+ elif provider_name == "ollama":
100
+ base = "http://127.0.0.1:11434/v1"
101
+ else:
102
+ raise LLMError(
103
+ "OpenAI-compatible provider requires OPENAI_COMPAT_BASE_URL "
104
+ "(for example https://api.groq.com/openai/v1 or http://127.0.0.1:11434/v1)."
105
+ )
106
+ api_key = key or "not-needed"
107
+ return OpenAICompatProvider(api_key, base_url=base), model_id
108
+
109
+ raise LLMError(
110
+ f"Unknown LLM provider '{provider_name}'. "
111
+ "Use openai, anthropic, or openai-compat (Groq/Ollama)."
112
+ )
@@ -0,0 +1,179 @@
1
+ """Budget, model fallback, circuit breaker, and token-cost helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping, Sequence
6
+ from typing import Any, NoReturn
7
+
8
+ from readyagents.errors import BudgetExceeded, CircuitOpen, LLMError
9
+ from readyagents.llm.base import parse_model_ref
10
+ from readyagents.logging import get_logger
11
+
12
+ log = get_logger("llm.resilience")
13
+
14
+ # USD per million tokens (prompt, completion). Approximate public list prices.
15
+ _RATES_PER_MILLION: dict[str, tuple[float, float]] = {
16
+ "gpt-4o-mini": (0.15, 0.60),
17
+ "gpt-4o": (2.50, 10.00),
18
+ "claude-sonnet-4-5": (3.00, 15.00),
19
+ "claude-3-5-sonnet": (3.00, 15.00),
20
+ "llama-3.1-8b-instant": (0.05, 0.08),
21
+ "default": (0.15, 0.60),
22
+ }
23
+
24
+
25
+ def cost_micros(
26
+ model: str,
27
+ prompt_tokens: int,
28
+ completion_tokens: int,
29
+ *,
30
+ rates: Mapping[str, tuple[float, float]] | None = None,
31
+ ) -> int:
32
+ """USD cost as integer millionths of a dollar (exact to sum)."""
33
+ table = rates or _RATES_PER_MILLION
34
+ key = (model or "").split(":")[-1].strip() or "default"
35
+ prompt_rate, completion_rate = table.get(key) or table.get("default") or (0.15, 0.60)
36
+ usd = (prompt_tokens / 1_000_000) * prompt_rate + (
37
+ completion_tokens / 1_000_000
38
+ ) * completion_rate
39
+ return int(round(usd * 1_000_000))
40
+
41
+
42
+ def normalize_usage(raw: Mapping[str, Any] | None, *, model: str = "") -> dict[str, int]:
43
+ data = dict(raw or {})
44
+ prompt_raw = data.get("prompt_tokens")
45
+ if prompt_raw is None:
46
+ prompt_raw = data.get("input_tokens")
47
+ prompt = _as_int(prompt_raw)
48
+ completion = _as_int(
49
+ data.get("completion_tokens")
50
+ if data.get("completion_tokens") is not None
51
+ else data.get("output_tokens")
52
+ )
53
+ total = _as_int(data.get("total_tokens")) or (prompt + completion)
54
+ micros = _as_int(data.get("cost_micros"))
55
+ if micros == 0 and (prompt or completion):
56
+ micros = cost_micros(model, prompt, completion)
57
+ out = {
58
+ "prompt_tokens": prompt,
59
+ "completion_tokens": completion,
60
+ "total_tokens": total,
61
+ "cost_micros": micros,
62
+ }
63
+ extra = _as_int(data.get("estimated_tokens"))
64
+ if extra:
65
+ out["estimated_tokens"] = extra
66
+ hits = _as_int(data.get("cache_hits"))
67
+ if hits:
68
+ out["cache_hits"] = hits
69
+ return out
70
+
71
+
72
+ def _as_int(raw: Any) -> int:
73
+ if raw is None:
74
+ return 0
75
+ try:
76
+ return int(raw)
77
+ except (TypeError, ValueError):
78
+ return 0
79
+
80
+
81
+ def usage_nonzero(usage: Mapping[str, int]) -> bool:
82
+ return any(int(v) for v in usage.values())
83
+
84
+
85
+ class CircuitBreaker:
86
+ """Process-local breaker keyed by model ref. Not distributed."""
87
+
88
+ def __init__(
89
+ self,
90
+ *,
91
+ failure_threshold: int = 3,
92
+ cooldown_seconds: float = 60.0,
93
+ clock: Callable[[], float] | None = None,
94
+ ) -> None:
95
+ import time
96
+
97
+ self.failure_threshold = max(1, int(failure_threshold))
98
+ self.cooldown_seconds = max(0.0, float(cooldown_seconds))
99
+ self._clock = clock or time.monotonic
100
+ self._fail_count: dict[str, int] = {}
101
+ self._open_until: dict[str, float] = {}
102
+
103
+ def allow(self, model: str) -> bool:
104
+ until = self._open_until.get(model)
105
+ if until is None:
106
+ return True
107
+ if self._clock() >= until:
108
+ self._open_until.pop(model, None)
109
+ self._fail_count[model] = 0
110
+ return True
111
+ return False
112
+
113
+ def record_success(self, model: str) -> None:
114
+ self._fail_count[model] = 0
115
+ self._open_until.pop(model, None)
116
+
117
+ def record_failure(self, model: str) -> None:
118
+ n = self._fail_count.get(model, 0) + 1
119
+ self._fail_count[model] = n
120
+ if n >= self.failure_threshold:
121
+ self._open_until[model] = self._clock() + self.cooldown_seconds
122
+
123
+ def is_open(self, model: str) -> bool:
124
+ return not self.allow(model)
125
+
126
+
127
+ def model_candidates(
128
+ primary: str | None,
129
+ *fallback_groups: Sequence[str] | None,
130
+ ) -> list[str]:
131
+ seen: list[str] = []
132
+ for group in ((primary,) if primary else (), *(fallback_groups or ())):
133
+ if not group:
134
+ continue
135
+ for item in group:
136
+ ref = str(item).strip()
137
+ if ref and ref not in seen:
138
+ seen.append(ref)
139
+ return seen
140
+
141
+
142
+ def model_id_for(ref: str) -> str:
143
+ if not ref:
144
+ return "mock"
145
+ if ":" in ref:
146
+ return parse_model_ref(ref)[1]
147
+ return ref
148
+
149
+
150
+ def check_budget(
151
+ usage: Mapping[str, int],
152
+ *,
153
+ max_tokens: int | None = None,
154
+ max_cost_micros: int | None = None,
155
+ ) -> None:
156
+ tokens = int(usage.get("total_tokens") or 0) + int(usage.get("estimated_tokens") or 0)
157
+ if max_tokens is not None and tokens >= max_tokens:
158
+ raise BudgetExceeded("tokens", tokens, max_tokens)
159
+ cost = int(usage.get("cost_micros") or 0)
160
+ if max_cost_micros is not None and cost >= max_cost_micros:
161
+ raise BudgetExceeded("cost_micros", cost, max_cost_micros)
162
+
163
+
164
+ def usd_to_micros(value: float | int | None) -> int | None:
165
+ if value is None:
166
+ return None
167
+ return int(round(float(value) * 1_000_000))
168
+
169
+
170
+ def raise_exhausted(
171
+ tried: Sequence[str], skipped: Sequence[str], last: BaseException | None
172
+ ) -> NoReturn:
173
+ if skipped and not tried:
174
+ raise CircuitOpen(skipped[0])
175
+ if last is not None:
176
+ raise last
177
+ if skipped:
178
+ raise CircuitOpen(skipped[0])
179
+ raise LLMError("No LLM model was available")
@@ -0,0 +1,286 @@
1
+ """Pure mapping between vendor tool-call payloads and engine ToolCall objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Sequence
7
+ from typing import Any
8
+
9
+ from readyagents.llm.base import Message, ToolCall
10
+
11
+
12
+ def parse_json_arguments(raw: Any) -> dict[str, Any]:
13
+ if raw is None:
14
+ return {}
15
+ if isinstance(raw, dict):
16
+ return dict(raw)
17
+ if isinstance(raw, str):
18
+ stripped = raw.strip()
19
+ if not stripped:
20
+ return {}
21
+ try:
22
+ parsed = json.loads(stripped)
23
+ except json.JSONDecodeError:
24
+ return {"_raw": raw}
25
+ if isinstance(parsed, dict):
26
+ return parsed
27
+ return {"value": parsed}
28
+ return {"value": raw}
29
+
30
+
31
+ def spec_from_tool(tool: Any) -> dict[str, Any]:
32
+ raw = getattr(tool, "schema", None)
33
+ if isinstance(raw, dict) and raw:
34
+ schema = dict(raw)
35
+ else:
36
+ schema = {"type": "object", "properties": {}}
37
+ return {
38
+ "name": str(getattr(tool, "name", "")),
39
+ "description": str(getattr(tool, "description", "") or ""),
40
+ "schema": schema,
41
+ }
42
+
43
+
44
+ def openai_tools_payload(tools: Sequence[dict[str, Any]] | None) -> list[dict[str, Any]] | None:
45
+ if not tools:
46
+ return None
47
+ out: list[dict[str, Any]] = []
48
+ for spec in tools:
49
+ if spec.get("type") == "function" and isinstance(spec.get("function"), dict):
50
+ out.append(dict(spec))
51
+ continue
52
+ name = spec.get("name")
53
+ if not name:
54
+ fn = spec.get("function") if isinstance(spec.get("function"), dict) else {}
55
+ name = fn.get("name")
56
+ if not name:
57
+ continue
58
+ out.append(
59
+ {
60
+ "type": "function",
61
+ "function": {
62
+ "name": name,
63
+ "description": fn.get("description") or spec.get("description") or "",
64
+ "parameters": fn.get("parameters")
65
+ or spec.get("schema")
66
+ or {"type": "object", "properties": {}},
67
+ },
68
+ }
69
+ )
70
+ continue
71
+ schema = (
72
+ spec.get("schema") or spec.get("parameters") or {"type": "object", "properties": {}}
73
+ )
74
+ out.append(
75
+ {
76
+ "type": "function",
77
+ "function": {
78
+ "name": name,
79
+ "description": spec.get("description") or "",
80
+ "parameters": schema,
81
+ },
82
+ }
83
+ )
84
+ return out or None
85
+
86
+
87
+ def anthropic_tools_payload(tools: Sequence[dict[str, Any]] | None) -> list[dict[str, Any]] | None:
88
+ if not tools:
89
+ return None
90
+ out: list[dict[str, Any]] = []
91
+ for spec in tools:
92
+ if spec.get("input_schema") and spec.get("name") and spec.get("type") != "function":
93
+ out.append(
94
+ {
95
+ "name": spec["name"],
96
+ "description": spec.get("description") or "",
97
+ "input_schema": spec["input_schema"],
98
+ }
99
+ )
100
+ continue
101
+ if spec.get("type") == "function":
102
+ fn = spec.get("function") if isinstance(spec.get("function"), dict) else {}
103
+ name = fn.get("name")
104
+ if not name:
105
+ continue
106
+ out.append(
107
+ {
108
+ "name": name,
109
+ "description": fn.get("description") or spec.get("description") or "",
110
+ "input_schema": fn.get("parameters")
111
+ or spec.get("schema")
112
+ or {"type": "object", "properties": {}},
113
+ }
114
+ )
115
+ continue
116
+ name = spec.get("name")
117
+ if not name:
118
+ continue
119
+ out.append(
120
+ {
121
+ "name": name,
122
+ "description": spec.get("description") or "",
123
+ "input_schema": spec.get("schema")
124
+ or spec.get("parameters")
125
+ or {"type": "object", "properties": {}},
126
+ }
127
+ )
128
+ return out or None
129
+
130
+
131
+ def tool_calls_from_openai_message(message: Any) -> list[ToolCall]:
132
+ raw_calls = getattr(message, "tool_calls", None)
133
+ if raw_calls is None and isinstance(message, dict):
134
+ raw_calls = message.get("tool_calls")
135
+ if not raw_calls:
136
+ return []
137
+ out: list[ToolCall] = []
138
+ for index, call in enumerate(raw_calls):
139
+ if isinstance(call, dict):
140
+ cid = str(call.get("id") or f"call_{index}")
141
+ fn = call.get("function") if isinstance(call.get("function"), dict) else {}
142
+ name = str(fn.get("name") or call.get("name") or "")
143
+ args = parse_json_arguments(fn.get("arguments") if fn else call.get("arguments"))
144
+ else:
145
+ cid = str(getattr(call, "id", None) or f"call_{index}")
146
+ fn = getattr(call, "function", None)
147
+ name = str(getattr(fn, "name", None) or getattr(call, "name", "") or "")
148
+ args_raw = (
149
+ getattr(fn, "arguments", None)
150
+ if fn is not None
151
+ else getattr(call, "arguments", None)
152
+ )
153
+ args = parse_json_arguments(args_raw)
154
+ if name:
155
+ out.append(ToolCall(id=cid, name=name, arguments=args))
156
+ return out
157
+
158
+
159
+ def tool_calls_from_anthropic_content(blocks: Any) -> list[ToolCall]:
160
+ if not blocks:
161
+ return []
162
+ out: list[ToolCall] = []
163
+ for index, block in enumerate(blocks):
164
+ if isinstance(block, dict):
165
+ btype = block.get("type")
166
+ name = block.get("name")
167
+ cid = block.get("id")
168
+ inp = block.get("input")
169
+ else:
170
+ btype = getattr(block, "type", None)
171
+ name = getattr(block, "name", None)
172
+ cid = getattr(block, "id", None)
173
+ inp = getattr(block, "input", None)
174
+ if str(btype) != "tool_use" or not name:
175
+ continue
176
+ out.append(
177
+ ToolCall(
178
+ id=str(cid or f"call_{index}"),
179
+ name=str(name),
180
+ arguments=parse_json_arguments(inp),
181
+ )
182
+ )
183
+ return out
184
+
185
+
186
+ def messages_to_openai(messages: Sequence[Message]) -> list[dict[str, Any]]:
187
+ payload: list[dict[str, Any]] = []
188
+ for message in messages:
189
+ if message.role == "tool":
190
+ row: dict[str, Any] = {
191
+ "role": "tool",
192
+ "content": message.content or "",
193
+ "tool_call_id": message.tool_call_id or "",
194
+ }
195
+ if message.name:
196
+ row["name"] = message.name
197
+ payload.append(row)
198
+ continue
199
+ row = {"role": message.role, "content": message.content}
200
+ if message.tool_calls:
201
+ row["tool_calls"] = [
202
+ {
203
+ "id": call.id,
204
+ "type": "function",
205
+ "function": {
206
+ "name": call.name,
207
+ "arguments": json.dumps(call.arguments, ensure_ascii=False),
208
+ },
209
+ }
210
+ for call in message.tool_calls
211
+ ]
212
+ payload.append(row)
213
+ return payload
214
+
215
+
216
+ def messages_to_anthropic(messages: Sequence[Message]) -> tuple[str, list[dict[str, Any]]]:
217
+ system_parts: list[str] = []
218
+ chat: list[dict[str, Any]] = []
219
+ pending: list[dict[str, Any]] = []
220
+
221
+ def flush_tool_results() -> None:
222
+ if pending:
223
+ chat.append({"role": "user", "content": list(pending)})
224
+ pending.clear()
225
+
226
+ for message in messages:
227
+ if message.role == "system":
228
+ if message.content:
229
+ system_parts.append(message.content)
230
+ continue
231
+ if message.role == "tool":
232
+ pending.append(
233
+ {
234
+ "type": "tool_result",
235
+ "tool_use_id": message.tool_call_id or "",
236
+ "content": message.content or "",
237
+ }
238
+ )
239
+ continue
240
+ flush_tool_results()
241
+ if message.role == "assistant" and message.tool_calls:
242
+ blocks: list[dict[str, Any]] = []
243
+ if message.content:
244
+ blocks.append({"type": "text", "text": message.content})
245
+ for call in message.tool_calls:
246
+ blocks.append(
247
+ {
248
+ "type": "tool_use",
249
+ "id": call.id,
250
+ "name": call.name,
251
+ "input": call.arguments,
252
+ }
253
+ )
254
+ chat.append({"role": "assistant", "content": blocks})
255
+ else:
256
+ role = "assistant" if message.role == "assistant" else "user"
257
+ chat.append({"role": role, "content": message.content})
258
+ flush_tool_results()
259
+ return "\n".join(system_parts).strip(), chat
260
+
261
+
262
+ def tool_calls_to_json(calls: Sequence[ToolCall] | None) -> list[dict[str, Any]]:
263
+ return [
264
+ {"id": call.id, "name": call.name, "arguments": dict(call.arguments)}
265
+ for call in (calls or [])
266
+ ]
267
+
268
+
269
+ def tool_calls_from_json(raw: Any) -> list[ToolCall]:
270
+ if not isinstance(raw, list):
271
+ return []
272
+ out: list[ToolCall] = []
273
+ for index, item in enumerate(raw):
274
+ if not isinstance(item, dict):
275
+ continue
276
+ name = str(item.get("name") or "")
277
+ if not name:
278
+ continue
279
+ out.append(
280
+ ToolCall(
281
+ id=str(item.get("id") or f"call_{index}"),
282
+ name=name,
283
+ arguments=parse_json_arguments(item.get("arguments")),
284
+ )
285
+ )
286
+ return out