rlmness-cli 0.2.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 (203) hide show
  1. rlmagent/__init__.py +1 -0
  2. rlmagent/__main__.py +8 -0
  3. rlmagent/acp/__init__.py +1 -0
  4. rlmagent/acp/runtime.py +71 -0
  5. rlmagent/acp/server.py +598 -0
  6. rlmagent/acp/updates.py +128 -0
  7. rlmagent/agentnet.py +237 -0
  8. rlmagent/agents/__init__.py +1 -0
  9. rlmagent/agents/approval.py +90 -0
  10. rlmagent/agents/bridge.py +109 -0
  11. rlmagent/agents/budget.py +212 -0
  12. rlmagent/agents/child_handles.py +46 -0
  13. rlmagent/agents/child_mailbox.py +30 -0
  14. rlmagent/agents/kernel_api.py +156 -0
  15. rlmagent/agents/output.py +34 -0
  16. rlmagent/agents/patch.py +107 -0
  17. rlmagent/agents/prompts.py +156 -0
  18. rlmagent/agents/trace.py +290 -0
  19. rlmagent/agents/tree.py +682 -0
  20. rlmagent/agents/wasm_api.py +105 -0
  21. rlmagent/agents/workspace.py +357 -0
  22. rlmagent/autopilot.py +447 -0
  23. rlmagent/backend.py +946 -0
  24. rlmagent/backend_context.py +74 -0
  25. rlmagent/cli/__init__.py +5 -0
  26. rlmagent/cli/config_cmd.py +177 -0
  27. rlmagent/cli/health.py +156 -0
  28. rlmagent/cli/main.py +1194 -0
  29. rlmagent/cli/update.py +188 -0
  30. rlmagent/clipboard.py +63 -0
  31. rlmagent/config/__init__.py +37 -0
  32. rlmagent/config/config.py +297 -0
  33. rlmagent/config/loader.py +119 -0
  34. rlmagent/config/prompts.py +124 -0
  35. rlmagent/config/secure_input.py +213 -0
  36. rlmagent/config/store.py +275 -0
  37. rlmagent/context/__init__.py +47 -0
  38. rlmagent/context/budget.py +557 -0
  39. rlmagent/contract.py +311 -0
  40. rlmagent/contract_run.py +232 -0
  41. rlmagent/conversation.py +2547 -0
  42. rlmagent/cron.py +77 -0
  43. rlmagent/directives.py +893 -0
  44. rlmagent/discovery.py +356 -0
  45. rlmagent/execution_log.py +66 -0
  46. rlmagent/factory.py +189 -0
  47. rlmagent/factory_graph.py +413 -0
  48. rlmagent/harness/__init__.py +63 -0
  49. rlmagent/harness/contracts/__init__.py +1 -0
  50. rlmagent/harness/contracts/stream.py +129 -0
  51. rlmagent/harness/contracts/tooling.py +127 -0
  52. rlmagent/harness/contracts/transcript/__init__.py +68 -0
  53. rlmagent/harness/contracts/transcript/_base.py +29 -0
  54. rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
  55. rlmagent/harness/contracts/transcript/entries.py +180 -0
  56. rlmagent/harness/contracts/transcript/segments.py +64 -0
  57. rlmagent/harness/contracts/values.py +6 -0
  58. rlmagent/harness/driver.py +397 -0
  59. rlmagent/harness/engine.py +489 -0
  60. rlmagent/harness/mending.py +153 -0
  61. rlmagent/harness/provider/__init__.py +1 -0
  62. rlmagent/harness/provider/base.py +27 -0
  63. rlmagent/harness/provider/wire.py +189 -0
  64. rlmagent/harness/py.typed +0 -0
  65. rlmagent/harness/session/__init__.py +5 -0
  66. rlmagent/harness/session/index.py +218 -0
  67. rlmagent/harness/session/lease.py +66 -0
  68. rlmagent/harness/session/records.py +118 -0
  69. rlmagent/harness/session/replay.py +165 -0
  70. rlmagent/harness/session/store.py +155 -0
  71. rlmagent/harness/session/summary.py +46 -0
  72. rlmagent/hints.py +47 -0
  73. rlmagent/history.py +92 -0
  74. rlmagent/hooks/__init__.py +1 -0
  75. rlmagent/hooks/hooks.py +1 -0
  76. rlmagent/instructions.py +157 -0
  77. rlmagent/job_runner.py +117 -0
  78. rlmagent/jobs.py +365 -0
  79. rlmagent/kept_agents.py +397 -0
  80. rlmagent/kernel/__init__.py +5 -0
  81. rlmagent/kernel/session.py +205 -0
  82. rlmagent/kernel/wasm.py +169 -0
  83. rlmagent/logs.py +208 -0
  84. rlmagent/managed_release.py +368 -0
  85. rlmagent/mcp.py +411 -0
  86. rlmagent/mcp_oauth.py +213 -0
  87. rlmagent/model/__init__.py +29 -0
  88. rlmagent/model/_claude/__init__.py +1 -0
  89. rlmagent/model/_claude/courier.py +117 -0
  90. rlmagent/model/_claude/emitter.py +378 -0
  91. rlmagent/model/_oai/__init__.py +1 -0
  92. rlmagent/model/_oai/helpers.py +256 -0
  93. rlmagent/model/_oai/normalize.py +263 -0
  94. rlmagent/model/_oai/parsers.py +317 -0
  95. rlmagent/model/_oai/payloads.py +226 -0
  96. rlmagent/model/_oai/transport.py +127 -0
  97. rlmagent/model/claude.py +552 -0
  98. rlmagent/model/copilot.py +142 -0
  99. rlmagent/model/correlation.py +21 -0
  100. rlmagent/model/deadline.py +19 -0
  101. rlmagent/model/discovery.py +181 -0
  102. rlmagent/model/external/__init__.py +11 -0
  103. rlmagent/model/external/acp.py +202 -0
  104. rlmagent/model/external/acp_install.py +109 -0
  105. rlmagent/model/external/cli.py +248 -0
  106. rlmagent/model/external/textcalls.py +57 -0
  107. rlmagent/model/failover.py +129 -0
  108. rlmagent/model/google.py +199 -0
  109. rlmagent/model/limits.py +46 -0
  110. rlmagent/model/oai_compatible.py +414 -0
  111. rlmagent/model/preferences.py +56 -0
  112. rlmagent/model/py.typed +0 -0
  113. rlmagent/model/registered_oauth.py +195 -0
  114. rlmagent/model/scripted.py +55 -0
  115. rlmagent/model/settings.py +271 -0
  116. rlmagent/model/subscription.py +398 -0
  117. rlmagent/model/token_bounds.py +15 -0
  118. rlmagent/model/transport/__init__.py +1 -0
  119. rlmagent/model/transport/backoff.py +87 -0
  120. rlmagent/model/transport/client.py +87 -0
  121. rlmagent/model/transport/faults.py +65 -0
  122. rlmagent/model/transport/responses_socket.py +223 -0
  123. rlmagent/model/vertex.py +93 -0
  124. rlmagent/network.py +123 -0
  125. rlmagent/packages.py +246 -0
  126. rlmagent/planning.py +84 -0
  127. rlmagent/playbook/__init__.py +1 -0
  128. rlmagent/playbook/apply.py +106 -0
  129. rlmagent/playbook/digest.py +63 -0
  130. rlmagent/playbook/improver.py +272 -0
  131. rlmagent/playbook/plan.py +99 -0
  132. rlmagent/playbook/store.py +67 -0
  133. rlmagent/plugins.py +327 -0
  134. rlmagent/policy.py +105 -0
  135. rlmagent/prompt_refs.py +57 -0
  136. rlmagent/prompts.py +331 -0
  137. rlmagent/prune.py +60 -0
  138. rlmagent/py.typed +0 -0
  139. rlmagent/reasoning.py +135 -0
  140. rlmagent/refinement/__init__.py +1 -0
  141. rlmagent/refinement/controller.py +448 -0
  142. rlmagent/refinement/engine.py +220 -0
  143. rlmagent/refinement/evidence.py +131 -0
  144. rlmagent/refinement/scoped.py +90 -0
  145. rlmagent/refinement/store.py +193 -0
  146. rlmagent/refresh.py +202 -0
  147. rlmagent/routing.py +52 -0
  148. rlmagent/runtime.py +575 -0
  149. rlmagent/safety.py +267 -0
  150. rlmagent/session_html.py +57 -0
  151. rlmagent/session_import.py +52 -0
  152. rlmagent/session_share.py +55 -0
  153. rlmagent/sessions.py +249 -0
  154. rlmagent/shell_sandbox.py +88 -0
  155. rlmagent/skillset.py +320 -0
  156. rlmagent/steering.py +148 -0
  157. rlmagent/stops.py +62 -0
  158. rlmagent/subscriptions.py +219 -0
  159. rlmagent/tasks.py +98 -0
  160. rlmagent/toolhost.py +310 -0
  161. rlmagent/tools/__init__.py +56 -0
  162. rlmagent/tools/_shared.py +210 -0
  163. rlmagent/tools/files.py +462 -0
  164. rlmagent/tools/kernel.py +93 -0
  165. rlmagent/tools/patch_tool.py +68 -0
  166. rlmagent/tools/search.py +188 -0
  167. rlmagent/trust.py +383 -0
  168. rlmagent/tui/__init__.py +1 -0
  169. rlmagent/tui/adapter.py +506 -0
  170. rlmagent/tui/app.py +711 -0
  171. rlmagent/tui/approval_screen.py +57 -0
  172. rlmagent/tui/choice_screen.py +47 -0
  173. rlmagent/tui/completions.py +116 -0
  174. rlmagent/tui/components.py +25 -0
  175. rlmagent/tui/composer.py +40 -0
  176. rlmagent/tui/factory_view.py +86 -0
  177. rlmagent/tui/inspect.py +193 -0
  178. rlmagent/tui/messages.py +215 -0
  179. rlmagent/tui/model_screen.py +307 -0
  180. rlmagent/tui/prompt.py +98 -0
  181. rlmagent/tui/question_screen.py +52 -0
  182. rlmagent/tui/replay.py +141 -0
  183. rlmagent/tui/rlm_agent.tcss +405 -0
  184. rlmagent/tui/search_box.py +91 -0
  185. rlmagent/tui/sidebar.py +182 -0
  186. rlmagent/tui/status.py +49 -0
  187. rlmagent/tui/steps.py +283 -0
  188. rlmagent/tui/suggest.py +778 -0
  189. rlmagent/tui/transcript.py +172 -0
  190. rlmagent/tui/trust_dialog.py +58 -0
  191. rlmagent/tui/worker_attach.py +141 -0
  192. rlmagent/ui/__init__.py +1 -0
  193. rlmagent/ui/render.py +211 -0
  194. rlmagent/undo.py +109 -0
  195. rlmagent/utils/__init__.py +1 -0
  196. rlmagent/websearch.py +75 -0
  197. rlmagent/worker.py +1275 -0
  198. rlmagent/worker_service.py +203 -0
  199. rlmness_cli-0.2.0.dist-info/METADATA +882 -0
  200. rlmness_cli-0.2.0.dist-info/RECORD +203 -0
  201. rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
  202. rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
  203. rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,256 @@
1
+ """Shared utilities for the OpenAI-compatible provider layer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from rlmagent.harness.contracts.transcript import CostBreakdown, UsageStats
10
+ from rlmagent.model.settings import ReasoningPolicy
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class ServerSentEvent:
15
+ """A single Server-Sent Event parsed from an HTTP stream."""
16
+
17
+ event: str
18
+ data: str
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Finish-reason mapping
23
+ # ---------------------------------------------------------------------------
24
+
25
+
26
+ def map_halt_reason(reason: str | None) -> str:
27
+ """Translate an OpenAI finish reason to rlm-agent's HaltReason vocabulary."""
28
+ match reason:
29
+ case "stop" | None:
30
+ return "stop"
31
+ case "length" | "max_tokens":
32
+ return "length"
33
+ case "tool_calls" | "function_call":
34
+ return "toolUse"
35
+ case _:
36
+ return "stop"
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Usage extraction
41
+ # ---------------------------------------------------------------------------
42
+
43
+
44
+ def extract_usage(raw: dict[str, Any] | None) -> UsageStats:
45
+ """Build a ``UsageStats`` from an OpenAI-style usage dictionary."""
46
+ if not raw:
47
+ return UsageStats()
48
+
49
+ input_tok = raw.get("prompt_tokens") or raw.get("input_tokens") or 0
50
+ output_tok = raw.get("completion_tokens") or raw.get("output_tokens") or 0
51
+ total = raw.get("total_tokens") or (int(input_tok) + int(output_tok))
52
+
53
+ # Chat Completions and Responses name the prompt breakdown differently.
54
+ cache_read = cache_write = 0
55
+ prompt_detail = raw.get("prompt_tokens_details") or raw.get("input_tokens_details")
56
+ if isinstance(prompt_detail, dict):
57
+ cache_read = int(prompt_detail.get("cached_tokens", 0) or 0)
58
+ cache_write = int(prompt_detail.get("cache_write_tokens", 0) or 0)
59
+
60
+ reasoning = None
61
+ comp_detail = raw.get("completion_tokens_details") or raw.get("output_tokens_details")
62
+ if isinstance(comp_detail, dict):
63
+ r = comp_detail.get("reasoning_tokens")
64
+ if r:
65
+ reasoning = int(r)
66
+
67
+ # Gateways such as OpenRouter report the billed cost in USD with usage.
68
+ cost = CostBreakdown()
69
+ reported = raw.get("cost")
70
+ if isinstance(reported, int | float) and not isinstance(reported, bool):
71
+ details = raw.get("cost_details")
72
+ details = details if isinstance(details, dict) else {}
73
+ cost = CostBreakdown(
74
+ total=float(reported),
75
+ input=float(details.get("upstream_inference_prompt_cost") or 0.0),
76
+ output=float(details.get("upstream_inference_completions_cost") or 0.0),
77
+ )
78
+
79
+ # The reported prompt count includes cached tokens; ``input`` keeps only
80
+ # the fresh part so every provider reports usage the same way.
81
+ return UsageStats(
82
+ cost=cost,
83
+ total_tokens=int(total),
84
+ input=max(0, int(input_tok) - cache_read - cache_write),
85
+ output=int(output_tok),
86
+ cache_read=cache_read,
87
+ cache_write=cache_write,
88
+ reasoning=reasoning,
89
+ )
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Endpoint selection
94
+ # ---------------------------------------------------------------------------
95
+
96
+ _RESPONSES_PREFIXES = ("o1", "o3", "o4")
97
+ _DEFAULT_OPENAI_BASE = "https://api.openai.com/v1"
98
+
99
+
100
+ def pick_endpoint(model: str, base_url: str) -> str:
101
+ """Return ``"responses"`` or ``"chat"`` for the given model and base URL."""
102
+ if base_url.rstrip("/") != _DEFAULT_OPENAI_BASE:
103
+ return "chat"
104
+ lower = model.lower()
105
+ for prefix in _RESPONSES_PREFIXES:
106
+ if lower.startswith(prefix):
107
+ return "responses"
108
+ return "chat"
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # JSON helpers
113
+ # ---------------------------------------------------------------------------
114
+
115
+
116
+ def try_parse_json(text: str) -> dict[str, Any] | None:
117
+ """Attempt to parse *text* as a JSON object, returning ``None`` on failure."""
118
+ try:
119
+ obj = json.loads(text)
120
+ return obj if isinstance(obj, dict) else None
121
+ except (json.JSONDecodeError, ValueError):
122
+ return None
123
+
124
+
125
+ def parse_tool_arguments(text: str) -> dict[str, Any]:
126
+ """Parse tool-call argument JSON, returning an empty dict on failure."""
127
+ if not text or not text.strip():
128
+ return {}
129
+ try:
130
+ obj = json.loads(text)
131
+ return obj if isinstance(obj, dict) else {}
132
+ except (json.JSONDecodeError, ValueError):
133
+ return {}
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # Reasoning parameters
138
+ # ---------------------------------------------------------------------------
139
+
140
+
141
+ # Model families that accept a reasoning-effort setting. Other models reject
142
+ # the parameter outright, so it is only sent to these.
143
+ _REASONING_FAMILIES = ("o1", "o3", "o4", "gpt-5", "gpt-oss")
144
+ # The highest level the API names; stronger local levels are clamped to it.
145
+ _EFFORT_CEILING = "high"
146
+ _EFFORTS = ("minimal", "low", "medium", "high")
147
+
148
+
149
+ def reasons(model: str) -> bool:
150
+ """Whether ``model`` takes a reasoning-effort parameter."""
151
+ name = model.lower().rsplit("/", 1)[-1]
152
+ return name.startswith(_REASONING_FAMILIES)
153
+
154
+
155
+ def build_reasoning_extra(
156
+ policy: ReasoningPolicy, *, model: str, endpoint: str,
157
+ ) -> dict[str, Any]:
158
+ """Reasoning parameters for one request, shaped for its endpoint."""
159
+ if not policy.enabled or not reasons(model):
160
+ return {}
161
+ effort = policy.effort or ("high" if policy.budget_tokens else "medium")
162
+ if effort not in _EFFORTS:
163
+ effort = _EFFORT_CEILING
164
+ if endpoint == "responses":
165
+ return {"reasoning": {"effort": effort}}
166
+ return {"reasoning_effort": effort}
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # Retry-After parsing
171
+ # ---------------------------------------------------------------------------
172
+
173
+
174
+ def parse_retry_after(header: str | None) -> float | None:
175
+ """Extract a wait duration in seconds from a ``Retry-After`` header."""
176
+ if header is None:
177
+ return None
178
+ try:
179
+ return max(0.0, float(header))
180
+ except (ValueError, TypeError):
181
+ return None
182
+
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # In-stream failure classification
186
+ # ---------------------------------------------------------------------------
187
+
188
+ # Substrings (matched against the lower-cased code + message) that mark a
189
+ # failure as a passing condition worth one more attempt.
190
+ _PASSING_FAILURE_HINTS = (
191
+ "overloaded",
192
+ "service_unavailable",
193
+ "temporarily_unavailable",
194
+ "rate_limit",
195
+ "internal_error",
196
+ "server_error",
197
+ "timeout",
198
+ )
199
+
200
+ # Rate-limit wording that actually means the account is out of budget; retrying
201
+ # those only burns the backoff budget before failing anyway.
202
+ _EXHAUSTED_QUOTA_HINTS = (
203
+ "gousagelimiterror",
204
+ "freeusagelimiterror",
205
+ "monthly usage limit reached",
206
+ "available balance",
207
+ "insufficient_quota",
208
+ "out of budget",
209
+ "quota exceeded",
210
+ "billing",
211
+ )
212
+
213
+
214
+ def stream_failure(event: str, data: dict[str, Any]) -> tuple[str | None, str | None] | None:
215
+ """Return ``(code, message)`` if this SSE event reports a failure, else ``None``."""
216
+ nested = data.get("error")
217
+ response = data.get("response")
218
+ is_failure = (
219
+ event in ("error", "response.failed")
220
+ or data.get("type") in ("error", "response.failed")
221
+ or isinstance(nested, dict)
222
+ )
223
+ if not is_failure:
224
+ return None
225
+
226
+ sources: list[dict[str, Any]] = [data]
227
+ if isinstance(nested, dict):
228
+ sources.append(nested)
229
+ if isinstance(response, dict):
230
+ for key in ("error", "last_error"):
231
+ found = response.get(key)
232
+ if isinstance(found, dict):
233
+ sources.append(found)
234
+
235
+ code: str | None = None
236
+ message: str | None = None
237
+ for source in sources:
238
+ raw_message = source.get("message")
239
+ if message is None and isinstance(raw_message, str) and raw_message:
240
+ message = raw_message
241
+ raw_code = source.get("code")
242
+ if code is None and isinstance(raw_code, str) and raw_code:
243
+ code = raw_code
244
+ if code is None and isinstance(nested, dict):
245
+ nested_type = nested.get("type")
246
+ if isinstance(nested_type, str) and nested_type:
247
+ code = nested_type
248
+ return code, message
249
+
250
+
251
+ def is_passing_failure(code: str | None, message: str | None) -> bool:
252
+ """Whether an in-stream failure looks transient and safe to reissue."""
253
+ text = " ".join(part for part in (code, message) if part).lower()
254
+ if not text or any(hint in text for hint in _EXHAUSTED_QUOTA_HINTS):
255
+ return False
256
+ return any(hint in text for hint in _PASSING_FAILURE_HINTS)
@@ -0,0 +1,263 @@
1
+ """Convert parsed stream signals into rlm-agent wire events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rlmagent.harness.contracts.transcript import (
6
+ CallBlock,
7
+ ModelEntry,
8
+ ReplyContent,
9
+ TextSegment,
10
+ ThoughtSegment,
11
+ UsageStats,
12
+ )
13
+ from rlmagent.harness.provider.wire import (
14
+ CallChunkEvent,
15
+ CallCloseEvent,
16
+ CallOpenEvent,
17
+ CompletionCause,
18
+ ContentChunkEvent,
19
+ ContentCloseEvent,
20
+ ContentOpenEvent,
21
+ FaultCause,
22
+ ReasoningChunkEvent,
23
+ ReasoningCloseEvent,
24
+ ReasoningOpenEvent,
25
+ StreamCloseEvent,
26
+ StreamFaultEvent,
27
+ StreamOpenEvent,
28
+ WireEvent,
29
+ )
30
+ from rlmagent.model._oai.helpers import extract_usage, map_halt_reason, parse_tool_arguments
31
+ from rlmagent.model._oai.parsers import (
32
+ CallArgFragment,
33
+ CallBegin,
34
+ CallResolved,
35
+ Finished,
36
+ Metadata,
37
+ ParsedSignal,
38
+ ParseFault,
39
+ ReasoningChunk,
40
+ TextChunk,
41
+ )
42
+
43
+
44
+ def _segment(kind: str, body: str) -> ReplyContent:
45
+ return TextSegment(text=body) if kind == "text" else ThoughtSegment(thinking=body)
46
+
47
+
48
+ class EventAssembler:
49
+ """Accumulates parsed signals and emits rlm-agent wire events."""
50
+
51
+ def __init__(self, *, model: str, provider: str, api: str) -> None:
52
+ self._model = model
53
+ self._provider = provider
54
+ self._api = api
55
+ self._response_id: str | None = None
56
+ self._response_model: str | None = None
57
+
58
+ # Ordered sequence of completed content blocks
59
+ self._blocks: list[ReplyContent] = []
60
+
61
+ # Open text/reasoning blocks by channel, in first-seen order. Chat
62
+ # Completions sends reasoning and answer as independent fields that can
63
+ # interleave (even within one chunk), so there each channel keeps one
64
+ # stable block until a boundary. Other APIs stream blocks in sequence,
65
+ # so opening one channel closes the other.
66
+ self._open: dict[str, tuple[int, list[str]]] = {}
67
+ self._independent = api == "chat"
68
+
69
+ # Usage and halt
70
+ self._usage: UsageStats = UsageStats()
71
+ self._halt: str = "stop"
72
+
73
+ # Content-index tracking
74
+ self._next_idx = 0
75
+ self._call_idx_map: dict[int, int] = {} # parser index → content index
76
+
77
+ # State flags
78
+ self._opened = False
79
+ self._faulted = False
80
+
81
+ # --- snapshot -----------------------------------------------------------
82
+
83
+ def _snapshot(self) -> ModelEntry:
84
+ """Build a ``ModelEntry`` reflecting the current accumulated state."""
85
+ blocks: list[ReplyContent] = list(self._blocks)
86
+
87
+ # Append partial content from open blocks, in content-index order
88
+ for kind, (_idx, chunks) in sorted(self._open.items(), key=lambda kv: kv[1][0]):
89
+ body = "".join(chunks)
90
+ if body:
91
+ blocks.append(_segment(kind, body))
92
+
93
+ return ModelEntry(
94
+ model=self._response_model or self._model,
95
+ provider=self._provider,
96
+ api=self._api,
97
+ response_model=self._response_model,
98
+ response_id=self._response_id,
99
+ content=blocks,
100
+ stop_reason=self._halt, # type: ignore[arg-type]
101
+ usage=self._usage,
102
+ )
103
+
104
+ def _ensure_open(self) -> list[WireEvent]:
105
+ """Emit ``StreamOpenEvent`` if the stream hasn't started yet."""
106
+ if self._opened:
107
+ return []
108
+ self._opened = True
109
+ return [StreamOpenEvent(partial=self._snapshot())]
110
+
111
+ # --- signal dispatch ----------------------------------------------------
112
+
113
+ def accept(self, signal: ParsedSignal) -> list[WireEvent]:
114
+ """Process one parsed signal and return any resulting wire events."""
115
+ if isinstance(signal, Metadata):
116
+ return self._on_metadata(signal)
117
+ if isinstance(signal, TextChunk):
118
+ return self._on_text(signal)
119
+ if isinstance(signal, ReasoningChunk):
120
+ return self._on_reasoning(signal)
121
+ if isinstance(signal, CallBegin):
122
+ return self._on_call_begin(signal)
123
+ if isinstance(signal, CallArgFragment):
124
+ return self._on_call_arg(signal)
125
+ if isinstance(signal, CallResolved):
126
+ return self._on_call_resolved(signal)
127
+ if isinstance(signal, Finished):
128
+ return self._on_finished(signal)
129
+ if isinstance(signal, ParseFault):
130
+ return self._on_fault(signal)
131
+ return []
132
+
133
+ # --- signal handlers ----------------------------------------------------
134
+
135
+ def _on_metadata(self, signal: Metadata) -> list[WireEvent]:
136
+ if signal.response_id:
137
+ self._response_id = signal.response_id
138
+ if signal.model:
139
+ self._response_model = signal.model
140
+ return []
141
+
142
+ def _on_text(self, signal: TextChunk) -> list[WireEvent]:
143
+ return self._on_channel("text", signal.text)
144
+
145
+ def _on_reasoning(self, signal: ReasoningChunk) -> list[WireEvent]:
146
+ return self._on_channel("reasoning", signal.text)
147
+
148
+ def _on_channel(self, kind: str, fragment: str) -> list[WireEvent]:
149
+ events = self._ensure_open()
150
+ if kind not in self._open:
151
+ if not self._independent:
152
+ events.extend(self._close_active())
153
+ idx = self._next_idx
154
+ self._next_idx += 1
155
+ self._open[kind] = (idx, [])
156
+ opener = ContentOpenEvent if kind == "text" else ReasoningOpenEvent
157
+ events.append(opener(content_index=idx, partial=self._snapshot()))
158
+ idx, chunks = self._open[kind]
159
+ chunks.append(fragment)
160
+ chunk_event = ContentChunkEvent if kind == "text" else ReasoningChunkEvent
161
+ events.append(chunk_event(content_index=idx, delta=fragment, partial=self._snapshot()))
162
+ return events
163
+
164
+ def _on_call_begin(self, signal: CallBegin) -> list[WireEvent]:
165
+ events = self._ensure_open()
166
+ events.extend(self._close_active())
167
+ idx = self._next_idx
168
+ self._next_idx += 1
169
+ self._call_idx_map[signal.index] = idx
170
+ events.append(CallOpenEvent(
171
+ content_index=idx,
172
+ partial=self._snapshot(),
173
+ ))
174
+ return events
175
+
176
+ def _on_call_arg(self, signal: CallArgFragment) -> list[WireEvent]:
177
+ idx = self._call_idx_map.get(signal.index)
178
+ if idx is None:
179
+ return []
180
+ return [CallChunkEvent(
181
+ content_index=idx,
182
+ delta=signal.fragment,
183
+ partial=self._snapshot(),
184
+ )]
185
+
186
+ def _on_call_resolved(self, signal: CallResolved) -> list[WireEvent]:
187
+ args = parse_tool_arguments(signal.arguments_json)
188
+ call = CallBlock(id=signal.call_id, name=signal.name, arguments=args)
189
+ self._blocks.append(call)
190
+ idx = self._call_idx_map.get(signal.index)
191
+ if idx is None:
192
+ return []
193
+ return [CallCloseEvent(
194
+ content_index=idx,
195
+ tool_call=call,
196
+ partial=self._snapshot(),
197
+ )]
198
+
199
+ def _on_finished(self, signal: Finished) -> list[WireEvent]:
200
+ if signal.finish_reason is not None:
201
+ self._halt = map_halt_reason(signal.finish_reason)
202
+ if signal.usage is not None:
203
+ self._usage = extract_usage(signal.usage)
204
+ return []
205
+
206
+ def _on_fault(self, signal: ParseFault) -> list[WireEvent]:
207
+ self._faulted = True
208
+ self._halt = "error"
209
+ events = self._ensure_open()
210
+ error_entry = ModelEntry(
211
+ model=self._response_model or self._model,
212
+ provider=self._provider,
213
+ api=self._api,
214
+ response_model=self._response_model,
215
+ response_id=self._response_id,
216
+ content=list(self._snapshot().content),
217
+ stop_reason="error",
218
+ error_message=signal.message,
219
+ usage=self._usage,
220
+ )
221
+ events.append(StreamFaultEvent(reason="error", error=error_entry))
222
+ return events
223
+
224
+ # --- block closing ------------------------------------------------------
225
+
226
+ def _close_active(self) -> list[WireEvent]:
227
+ """Close every open text/reasoning block, committing them in index order."""
228
+ events: list[WireEvent] = []
229
+ for kind, (idx, chunks) in sorted(self._open.items(), key=lambda kv: kv[1][0]):
230
+ content = "".join(chunks)
231
+ self._blocks.append(_segment(kind, content))
232
+ del self._open[kind]
233
+ closer = ContentCloseEvent if kind == "text" else ReasoningCloseEvent
234
+ events.append(closer(content_index=idx, content=content, partial=self._snapshot()))
235
+ return events
236
+
237
+ # --- finalisation -------------------------------------------------------
238
+
239
+ def finalize(self) -> list[WireEvent]:
240
+ """Close all open content blocks and emit the terminal event."""
241
+ if self._faulted:
242
+ return []
243
+
244
+ events: list[WireEvent] = []
245
+ events.extend(self._close_active())
246
+
247
+ message = self._snapshot()
248
+
249
+ if not self._opened:
250
+ events.append(StreamOpenEvent(partial=message))
251
+
252
+ if self._halt in ("error", "aborted"):
253
+ cause: FaultCause = "aborted" if self._halt == "aborted" else "error"
254
+ events.append(StreamFaultEvent(reason=cause, error=message))
255
+ else:
256
+ reason: CompletionCause = "stop"
257
+ if self._halt == "length":
258
+ reason = "length"
259
+ elif self._halt == "toolUse":
260
+ reason = "toolUse"
261
+ events.append(StreamCloseEvent(reason=reason, message=message))
262
+
263
+ return events