steerable-agent-runtime 0.1.0__tar.gz

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 (26) hide show
  1. steerable_agent_runtime-0.1.0/PKG-INFO +61 -0
  2. steerable_agent_runtime-0.1.0/README.md +36 -0
  3. steerable_agent_runtime-0.1.0/pyproject.toml +44 -0
  4. steerable_agent_runtime-0.1.0/setup.cfg +4 -0
  5. steerable_agent_runtime-0.1.0/src/steerable_agent_runtime/__init__.py +34 -0
  6. steerable_agent_runtime-0.1.0/src/steerable_agent_runtime/errors.py +34 -0
  7. steerable_agent_runtime-0.1.0/src/steerable_agent_runtime/llm/__init__.py +104 -0
  8. steerable_agent_runtime-0.1.0/src/steerable_agent_runtime/llm/anthropic_native.py +261 -0
  9. steerable_agent_runtime-0.1.0/src/steerable_agent_runtime/llm/openai_compat.py +256 -0
  10. steerable_agent_runtime-0.1.0/src/steerable_agent_runtime/storage/__init__.py +82 -0
  11. steerable_agent_runtime-0.1.0/src/steerable_agent_runtime/storage/in_memory.py +151 -0
  12. steerable_agent_runtime-0.1.0/src/steerable_agent_runtime/storage/sqlalchemy_store.py +340 -0
  13. steerable_agent_runtime-0.1.0/src/steerable_agent_runtime/tools.py +251 -0
  14. steerable_agent_runtime-0.1.0/src/steerable_agent_runtime/transport/__init__.py +39 -0
  15. steerable_agent_runtime-0.1.0/src/steerable_agent_runtime/transport/fastapi_sse.py +116 -0
  16. steerable_agent_runtime-0.1.0/src/steerable_agent_runtime/transport/stdio_jsonrpc.py +319 -0
  17. steerable_agent_runtime-0.1.0/src/steerable_agent_runtime.egg-info/PKG-INFO +61 -0
  18. steerable_agent_runtime-0.1.0/src/steerable_agent_runtime.egg-info/SOURCES.txt +24 -0
  19. steerable_agent_runtime-0.1.0/src/steerable_agent_runtime.egg-info/dependency_links.txt +1 -0
  20. steerable_agent_runtime-0.1.0/src/steerable_agent_runtime.egg-info/requires.txt +23 -0
  21. steerable_agent_runtime-0.1.0/src/steerable_agent_runtime.egg-info/top_level.txt +1 -0
  22. steerable_agent_runtime-0.1.0/tests/test_in_memory_storage.py +174 -0
  23. steerable_agent_runtime-0.1.0/tests/test_llm_wire_helpers.py +213 -0
  24. steerable_agent_runtime-0.1.0/tests/test_tool_router.py +167 -0
  25. steerable_agent_runtime-0.1.0/tests/test_transport_jsonrpc.py +134 -0
  26. steerable_agent_runtime-0.1.0/tests/test_transport_sse.py +75 -0
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: steerable-agent-runtime
3
+ Version: 0.1.0
4
+ Summary: Steerable agent runtime: LLM, tool, storage, and transport adapters.
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: pydantic>=2.10.0
8
+ Requires-Dist: steerable-agent-protocol<1.0.0,>=0.1.0
9
+ Requires-Dist: steerable-agent-harness<1.0.0,>=0.1.0
10
+ Provides-Extra: sqlalchemy
11
+ Requires-Dist: sqlalchemy>=2.0; extra == "sqlalchemy"
12
+ Provides-Extra: fastapi
13
+ Requires-Dist: fastapi>=0.110; extra == "fastapi"
14
+ Requires-Dist: starlette>=0.37; extra == "fastapi"
15
+ Provides-Extra: openai
16
+ Requires-Dist: httpx>=0.27; extra == "openai"
17
+ Provides-Extra: anthropic
18
+ Requires-Dist: anthropic>=0.40; extra == "anthropic"
19
+ Provides-Extra: all
20
+ Requires-Dist: sqlalchemy>=2.0; extra == "all"
21
+ Requires-Dist: fastapi>=0.110; extra == "all"
22
+ Requires-Dist: starlette>=0.37; extra == "all"
23
+ Requires-Dist: httpx>=0.27; extra == "all"
24
+ Requires-Dist: anthropic>=0.40; extra == "all"
25
+
26
+ # steerable-agent-runtime
27
+
28
+ Tier 3 runtime for the Steerable framework.
29
+
30
+ Provides four orthogonal pluggable adapters:
31
+
32
+ - `LLMProvider` — chat-completion / streaming / tool-call abstraction, with
33
+ reference implementations for OpenAI-compatible servers (covers OpenAI,
34
+ Ollama, vLLM, SiliconFlow, etc.) and Anthropic native.
35
+ - `ToolRouter` — in-process tool registry. Auto-classifies tools into
36
+ `ToolMode`s using `steerable_agent_harness.policy`, supports per-tool
37
+ permission overrides, and dispatches `ToolCall` → `ToolResult`.
38
+ - `StorageAdapter` — persistence interface for `AgentSession`, `ChatMessage`,
39
+ `ChatAgent`, and `HarnessTrace + spans + events`. Reference impls: in-memory
40
+ (default for sidecar/dev) and SQLAlchemy (for hosted backends).
41
+ - `TransportAdapter` — wire format. `FastAPISseTransport` exports SSE for
42
+ hosted setups; `StdioJsonRpcTransport` powers the steerable-sidecar.
43
+
44
+ The runtime is **Python only** by design — frontends never depend on it
45
+ directly. Browsers/Electron consume runtime output via either the SSE transport
46
+ (over HTTP) or the stdio JSON-RPC transport (sidecar pattern).
47
+
48
+ ## Install
49
+
50
+ ```bash
51
+ pip install steerable-agent-runtime[all]
52
+ ```
53
+
54
+ Selectively install just the bits you need:
55
+
56
+ ```bash
57
+ pip install "steerable-agent-runtime[openai]"
58
+ pip install "steerable-agent-runtime[anthropic]"
59
+ pip install "steerable-agent-runtime[sqlalchemy]"
60
+ pip install "steerable-agent-runtime[fastapi]"
61
+ ```
@@ -0,0 +1,36 @@
1
+ # steerable-agent-runtime
2
+
3
+ Tier 3 runtime for the Steerable framework.
4
+
5
+ Provides four orthogonal pluggable adapters:
6
+
7
+ - `LLMProvider` — chat-completion / streaming / tool-call abstraction, with
8
+ reference implementations for OpenAI-compatible servers (covers OpenAI,
9
+ Ollama, vLLM, SiliconFlow, etc.) and Anthropic native.
10
+ - `ToolRouter` — in-process tool registry. Auto-classifies tools into
11
+ `ToolMode`s using `steerable_agent_harness.policy`, supports per-tool
12
+ permission overrides, and dispatches `ToolCall` → `ToolResult`.
13
+ - `StorageAdapter` — persistence interface for `AgentSession`, `ChatMessage`,
14
+ `ChatAgent`, and `HarnessTrace + spans + events`. Reference impls: in-memory
15
+ (default for sidecar/dev) and SQLAlchemy (for hosted backends).
16
+ - `TransportAdapter` — wire format. `FastAPISseTransport` exports SSE for
17
+ hosted setups; `StdioJsonRpcTransport` powers the steerable-sidecar.
18
+
19
+ The runtime is **Python only** by design — frontends never depend on it
20
+ directly. Browsers/Electron consume runtime output via either the SSE transport
21
+ (over HTTP) or the stdio JSON-RPC transport (sidecar pattern).
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pip install steerable-agent-runtime[all]
27
+ ```
28
+
29
+ Selectively install just the bits you need:
30
+
31
+ ```bash
32
+ pip install "steerable-agent-runtime[openai]"
33
+ pip install "steerable-agent-runtime[anthropic]"
34
+ pip install "steerable-agent-runtime[sqlalchemy]"
35
+ pip install "steerable-agent-runtime[fastapi]"
36
+ ```
@@ -0,0 +1,44 @@
1
+ [project]
2
+ name = "steerable-agent-runtime"
3
+ version = "0.1.0"
4
+ description = "Steerable agent runtime: LLM, tool, storage, and transport adapters."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ # Pre-1.0 inter-package pins use `>=X,<1.0.0` ranges so release-please can
8
+ # bump individual packages without forcing a coordinated re-pin here.
9
+ # See packages/agent-harness/py/pyproject.toml for the rationale.
10
+ dependencies = [
11
+ "pydantic>=2.10.0",
12
+ "steerable-agent-protocol>=0.1.0,<1.0.0",
13
+ "steerable-agent-harness>=0.1.0,<1.0.0",
14
+ ]
15
+
16
+ [project.optional-dependencies]
17
+ sqlalchemy = ["sqlalchemy>=2.0"]
18
+ fastapi = ["fastapi>=0.110", "starlette>=0.37"]
19
+ openai = ["httpx>=0.27"]
20
+ anthropic = ["anthropic>=0.40"]
21
+ all = [
22
+ "sqlalchemy>=2.0",
23
+ "fastapi>=0.110",
24
+ "starlette>=0.37",
25
+ "httpx>=0.27",
26
+ "anthropic>=0.40",
27
+ ]
28
+
29
+ [build-system]
30
+ requires = ["setuptools>=68", "wheel"]
31
+ build-backend = "setuptools.build_meta"
32
+
33
+ [tool.setuptools]
34
+ package-dir = {"" = "src"}
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
38
+
39
+ [tool.uv.sources]
40
+ steerable-agent-protocol = { workspace = true }
41
+ steerable-agent-harness = { workspace = true }
42
+
43
+ [tool.pytest.ini_options]
44
+ asyncio_mode = "auto"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,34 @@
1
+ """Steerable agent runtime — Tier 3 adapter package."""
2
+
3
+ from .errors import (
4
+ BudgetExhaustedError,
5
+ PolicyDeniedError,
6
+ RuntimeError as SteerableRuntimeError,
7
+ StorageError,
8
+ ToolDispatchError,
9
+ TransportError,
10
+ )
11
+ from .llm import LLMMessage, LLMProvider, LLMStreamChunk, LLMUsage
12
+ from .storage import StorageAdapter
13
+ from .tools import RegisteredTool, ToolRouter, tool
14
+ from .transport import TransportAdapter
15
+
16
+ __all__ = [
17
+ "BudgetExhaustedError",
18
+ "PolicyDeniedError",
19
+ "SteerableRuntimeError",
20
+ "StorageError",
21
+ "ToolDispatchError",
22
+ "TransportError",
23
+ "LLMMessage",
24
+ "LLMProvider",
25
+ "LLMStreamChunk",
26
+ "LLMUsage",
27
+ "RegisteredTool",
28
+ "ToolRouter",
29
+ "tool",
30
+ "StorageAdapter",
31
+ "TransportAdapter",
32
+ ]
33
+
34
+ __version__ = "0.1.0"
@@ -0,0 +1,34 @@
1
+ """Common runtime exceptions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class RuntimeError(Exception): # noqa: A001 - intentional override of builtin
9
+ """Base class for all steerable-agent-runtime errors."""
10
+
11
+ def __init__(self, message: str, *, data: Any | None = None) -> None:
12
+ super().__init__(message)
13
+ self.message = message
14
+ self.data = data
15
+
16
+
17
+ class StorageError(RuntimeError):
18
+ """Persistence layer failure."""
19
+
20
+
21
+ class ToolDispatchError(RuntimeError):
22
+ """Tool router could not satisfy a ToolCall."""
23
+
24
+
25
+ class PolicyDeniedError(ToolDispatchError):
26
+ """A tool call was denied by policy (e.g. destructive without consent)."""
27
+
28
+
29
+ class BudgetExhaustedError(RuntimeError):
30
+ """The harness budget would be violated by the next operation."""
31
+
32
+
33
+ class TransportError(RuntimeError):
34
+ """Wire-level failure (SSE close, JSON-RPC parse error, etc.)."""
@@ -0,0 +1,104 @@
1
+ """LLMProvider interface and reference implementations.
2
+
3
+ The runtime intentionally keeps LLMProvider small. Higher-level concerns
4
+ (retry, budget, multi-step orchestration) live in `steerable_agent_harness`.
5
+
6
+ The interface speaks the protocol-level types (`ToolCall`, `ToolResult`,
7
+ `ChatMessage`) but accepts a slightly looser `LLMMessage` shape for inputs so
8
+ callers do not have to materialise full ChatMessage records when constructing
9
+ prompts.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import AsyncIterator, Iterable, Sequence
15
+ from dataclasses import dataclass, field
16
+ from typing import Any, Literal, Protocol, runtime_checkable
17
+
18
+ from steerable_agent_protocol.generated import ToolCall
19
+
20
+ LLMRole = Literal["system", "user", "assistant", "tool"]
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class LLMMessage:
25
+ """A single chat-message item passed to an LLMProvider."""
26
+
27
+ role: LLMRole
28
+ content: str
29
+ name: str | None = None
30
+ tool_call_id: str | None = None
31
+ tool_calls: list[ToolCall] | None = None
32
+
33
+
34
+ @dataclass(slots=True)
35
+ class LLMUsage:
36
+ prompt_tokens: int = 0
37
+ completion_tokens: int = 0
38
+ total_tokens: int = 0
39
+
40
+
41
+ @dataclass(slots=True)
42
+ class LLMStreamChunk:
43
+ """Provider-agnostic stream chunk."""
44
+
45
+ content_delta: str | None = None
46
+ reasoning_delta: str | None = None
47
+ tool_call_delta: ToolCall | None = None
48
+ finish_reason: str | None = None
49
+ usage: LLMUsage | None = None
50
+ raw: Any | None = None
51
+
52
+
53
+ @runtime_checkable
54
+ class LLMProvider(Protocol):
55
+ """Async chat-completion adapter.
56
+
57
+ Implementations must support both `complete()` (one-shot) and `stream()`
58
+ (incremental). Both flavors must:
59
+ * Accept a sequence of LLMMessage records.
60
+ * Optionally accept a list of tool descriptors (already in OpenAI
61
+ function-calling shape; providers that need a different shape transform
62
+ internally).
63
+ * Return / yield content alongside any tool calls the model proposed.
64
+ * Surface usage tokens whenever the upstream provider reports them.
65
+ """
66
+
67
+ name: str
68
+ model: str
69
+
70
+ async def complete(
71
+ self,
72
+ messages: Sequence[LLMMessage],
73
+ *,
74
+ tools: Iterable[dict[str, Any]] | None = None,
75
+ temperature: float | None = None,
76
+ max_tokens: int | None = None,
77
+ **kwargs: Any,
78
+ ) -> tuple[LLMMessage, LLMUsage]:
79
+ ...
80
+
81
+ def stream(
82
+ self,
83
+ messages: Sequence[LLMMessage],
84
+ *,
85
+ tools: Iterable[dict[str, Any]] | None = None,
86
+ temperature: float | None = None,
87
+ max_tokens: int | None = None,
88
+ **kwargs: Any,
89
+ ) -> AsyncIterator[LLMStreamChunk]:
90
+ ...
91
+
92
+
93
+ from .openai_compat import OpenAICompatProvider # noqa: E402
94
+ from .anthropic_native import AnthropicProvider # noqa: E402
95
+
96
+ __all__ = [
97
+ "LLMMessage",
98
+ "LLMProvider",
99
+ "LLMRole",
100
+ "LLMStreamChunk",
101
+ "LLMUsage",
102
+ "OpenAICompatProvider",
103
+ "AnthropicProvider",
104
+ ]
@@ -0,0 +1,261 @@
1
+ """Anthropic native-protocol provider.
2
+
3
+ Why a separate provider instead of always using OpenAI-compatible endpoints?
4
+ Some hosted gateways aggressively buffer the OpenAI-compatible SSE stream and
5
+ break true byte-level streaming. Going directly to the Anthropic native
6
+ ``/v1/messages`` endpoint (or its vendor mirror) produces sub-second first-byte
7
+ latency under those gateways.
8
+
9
+ This implementation is intentionally a thin shim around the official
10
+ ``anthropic`` Python SDK so we inherit auth, retries, and SSE parsing.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from collections.abc import AsyncIterator, Iterable, Sequence
17
+ from dataclasses import dataclass
18
+ from typing import Any
19
+
20
+ from steerable_agent_protocol.generated import ToolCall
21
+
22
+ from . import LLMMessage, LLMStreamChunk, LLMUsage
23
+
24
+
25
+ @dataclass(slots=True)
26
+ class AnthropicProvider:
27
+ name: str
28
+ model: str
29
+ api_key: str | None = None
30
+ base_url: str | None = None
31
+ default_temperature: float | None = None
32
+ default_max_tokens: int = 1024
33
+
34
+ async def complete(
35
+ self,
36
+ messages: Sequence[LLMMessage],
37
+ *,
38
+ tools: Iterable[dict[str, Any]] | None = None,
39
+ temperature: float | None = None,
40
+ max_tokens: int | None = None,
41
+ **kwargs: Any,
42
+ ) -> tuple[LLMMessage, LLMUsage]:
43
+ client = self._client()
44
+ body = self._build_body(
45
+ messages=messages,
46
+ tools=tools,
47
+ temperature=temperature,
48
+ max_tokens=max_tokens,
49
+ extra=kwargs,
50
+ )
51
+ message = await client.messages.create(**body)
52
+ text_chunks: list[str] = []
53
+ tool_calls: list[ToolCall] = []
54
+ for block in message.content or []:
55
+ block_type = getattr(block, "type", None)
56
+ if block_type == "text":
57
+ text_chunks.append(getattr(block, "text", "") or "")
58
+ elif block_type == "tool_use":
59
+ tool_calls.append(
60
+ ToolCall(
61
+ id=getattr(block, "id", "") or "",
62
+ name=getattr(block, "name", "") or "",
63
+ arguments=getattr(block, "input", {}) or {},
64
+ )
65
+ )
66
+ usage = getattr(message, "usage", None)
67
+ out_usage = LLMUsage(
68
+ prompt_tokens=int(getattr(usage, "input_tokens", 0) or 0),
69
+ completion_tokens=int(getattr(usage, "output_tokens", 0) or 0),
70
+ total_tokens=int(
71
+ (getattr(usage, "input_tokens", 0) or 0)
72
+ + (getattr(usage, "output_tokens", 0) or 0)
73
+ ),
74
+ )
75
+ return (
76
+ LLMMessage(
77
+ role="assistant",
78
+ content="".join(text_chunks),
79
+ tool_calls=tool_calls or None,
80
+ ),
81
+ out_usage,
82
+ )
83
+
84
+ async def stream( # type: ignore[override]
85
+ self,
86
+ messages: Sequence[LLMMessage],
87
+ *,
88
+ tools: Iterable[dict[str, Any]] | None = None,
89
+ temperature: float | None = None,
90
+ max_tokens: int | None = None,
91
+ **kwargs: Any,
92
+ ) -> AsyncIterator[LLMStreamChunk]:
93
+ client = self._client()
94
+ body = self._build_body(
95
+ messages=messages,
96
+ tools=tools,
97
+ temperature=temperature,
98
+ max_tokens=max_tokens,
99
+ extra=kwargs,
100
+ )
101
+ async with client.messages.stream(**body) as stream:
102
+ async for event in stream:
103
+ chunk = _parse_anthropic_event(event)
104
+ if chunk is not None:
105
+ yield chunk
106
+ final = await stream.get_final_message()
107
+ usage = getattr(final, "usage", None)
108
+ if usage is not None:
109
+ yield LLMStreamChunk(
110
+ usage=LLMUsage(
111
+ prompt_tokens=int(getattr(usage, "input_tokens", 0) or 0),
112
+ completion_tokens=int(getattr(usage, "output_tokens", 0) or 0),
113
+ total_tokens=int(
114
+ (getattr(usage, "input_tokens", 0) or 0)
115
+ + (getattr(usage, "output_tokens", 0) or 0)
116
+ ),
117
+ ),
118
+ finish_reason="stop",
119
+ )
120
+
121
+ # ------------------------------------------------------------------
122
+ # Helpers
123
+ # ------------------------------------------------------------------
124
+
125
+ def _client(self) -> Any:
126
+ from anthropic import AsyncAnthropic # local import keeps optional dep optional
127
+
128
+ kwargs: dict[str, Any] = {}
129
+ if self.api_key:
130
+ kwargs["api_key"] = self.api_key
131
+ if self.base_url:
132
+ kwargs["base_url"] = self.base_url
133
+ return AsyncAnthropic(**kwargs)
134
+
135
+ def _build_body(
136
+ self,
137
+ *,
138
+ messages: Sequence[LLMMessage],
139
+ tools: Iterable[dict[str, Any]] | None,
140
+ temperature: float | None,
141
+ max_tokens: int | None,
142
+ extra: dict[str, Any],
143
+ ) -> dict[str, Any]:
144
+ system_text, formatted = _split_system_and_messages(messages)
145
+ body: dict[str, Any] = {
146
+ "model": self.model,
147
+ "messages": formatted,
148
+ "max_tokens": max_tokens or self.default_max_tokens,
149
+ }
150
+ if system_text:
151
+ body["system"] = system_text
152
+ eff_temperature = temperature if temperature is not None else self.default_temperature
153
+ if eff_temperature is not None:
154
+ body["temperature"] = eff_temperature
155
+ if tools:
156
+ anth_tools = [_openai_tool_to_anthropic(t) for t in tools if isinstance(t, dict)]
157
+ if anth_tools:
158
+ body["tools"] = anth_tools
159
+ body.update(extra)
160
+ return body
161
+
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # Helpers (pure functions kept module-level for unit-testability)
165
+ # ---------------------------------------------------------------------------
166
+
167
+
168
+ def _split_system_and_messages(
169
+ messages: Sequence[LLMMessage],
170
+ ) -> tuple[str | None, list[dict[str, Any]]]:
171
+ system_chunks: list[str] = []
172
+ out: list[dict[str, Any]] = []
173
+ for message in messages:
174
+ if message.role == "system":
175
+ if message.content:
176
+ system_chunks.append(message.content)
177
+ continue
178
+ if message.role == "tool":
179
+ out.append(
180
+ {
181
+ "role": "user",
182
+ "content": [
183
+ {
184
+ "type": "tool_result",
185
+ "tool_use_id": message.tool_call_id or "",
186
+ "content": message.content,
187
+ }
188
+ ],
189
+ }
190
+ )
191
+ continue
192
+ if message.tool_calls:
193
+ blocks: list[dict[str, Any]] = []
194
+ if message.content:
195
+ blocks.append({"type": "text", "text": message.content})
196
+ for tc in message.tool_calls:
197
+ blocks.append(
198
+ {
199
+ "type": "tool_use",
200
+ "id": tc.id or "",
201
+ "name": tc.name,
202
+ "input": tc.arguments or {},
203
+ }
204
+ )
205
+ out.append({"role": "assistant", "content": blocks})
206
+ continue
207
+ out.append({"role": message.role, "content": message.content})
208
+ system = "\n\n".join(system_chunks) if system_chunks else None
209
+ return system, out
210
+
211
+
212
+ def _openai_tool_to_anthropic(tool: dict[str, Any]) -> dict[str, Any]:
213
+ if "name" in tool and "input_schema" in tool:
214
+ return tool # already anthropic-shaped
215
+ function = tool.get("function") or {}
216
+ return {
217
+ "name": function.get("name") or tool.get("name"),
218
+ "description": function.get("description") or tool.get("description") or "",
219
+ "input_schema": function.get("parameters") or {"type": "object", "properties": {}},
220
+ }
221
+
222
+
223
+ def _parse_anthropic_event(event: Any) -> LLMStreamChunk | None:
224
+ event_type = getattr(event, "type", None)
225
+ if event_type == "content_block_delta":
226
+ delta = getattr(event, "delta", None)
227
+ delta_type = getattr(delta, "type", None)
228
+ if delta_type == "text_delta":
229
+ return LLMStreamChunk(content_delta=getattr(delta, "text", "") or None, raw=event)
230
+ if delta_type == "thinking_delta":
231
+ return LLMStreamChunk(
232
+ reasoning_delta=getattr(delta, "thinking", "") or None,
233
+ raw=event,
234
+ )
235
+ if delta_type == "input_json_delta":
236
+ partial = getattr(delta, "partial_json", "") or ""
237
+ try:
238
+ args = json.loads(partial) if partial else {}
239
+ except json.JSONDecodeError:
240
+ args = {}
241
+ return LLMStreamChunk(
242
+ tool_call_delta=ToolCall(id="", name="", arguments=args),
243
+ raw=event,
244
+ )
245
+ if event_type == "content_block_start":
246
+ block = getattr(event, "content_block", None)
247
+ if getattr(block, "type", None) == "tool_use":
248
+ return LLMStreamChunk(
249
+ tool_call_delta=ToolCall(
250
+ id=getattr(block, "id", "") or "",
251
+ name=getattr(block, "name", "") or "",
252
+ arguments={},
253
+ ),
254
+ raw=event,
255
+ )
256
+ if event_type == "message_delta":
257
+ delta = getattr(event, "delta", None)
258
+ finish_reason = getattr(delta, "stop_reason", None)
259
+ if finish_reason:
260
+ return LLMStreamChunk(finish_reason=str(finish_reason), raw=event)
261
+ return None