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,317 @@
1
+ """Streaming response decoders for Chat Completions and Responses APIs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Protocol
7
+
8
+ from rlmagent.model._oai.helpers import stream_failure
9
+
10
+ # ---------------------------------------------------------------------------
11
+ # Parsed signal types
12
+ # ---------------------------------------------------------------------------
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class TextChunk:
17
+ """An incremental fragment of assistant text content."""
18
+
19
+ text: str
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class ReasoningChunk:
24
+ """An incremental fragment of reasoning / chain-of-thought output."""
25
+
26
+ text: str
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class CallBegin:
31
+ """Marks the start of a new tool call at the given parser index."""
32
+
33
+ index: int
34
+ call_id: str
35
+ name: str
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class CallArgFragment:
40
+ """An incremental fragment of a tool call's JSON argument string."""
41
+
42
+ index: int
43
+ fragment: str
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class CallResolved:
48
+ """A fully reconstructed tool call ready for execution."""
49
+
50
+ index: int
51
+ call_id: str
52
+ name: str
53
+ arguments_json: str
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class Metadata:
58
+ """Response-level metadata received at the start of a stream."""
59
+
60
+ response_id: str | None = None
61
+ model: str | None = None
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class Finished:
66
+ """Marks the end of the model response."""
67
+
68
+ finish_reason: str | None = None
69
+ usage: dict[str, Any] | None = None
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class ParseFault:
74
+ """Signals a parsing error in the response stream."""
75
+
76
+ message: str
77
+
78
+
79
+ type ParsedSignal = (
80
+ TextChunk
81
+ | ReasoningChunk
82
+ | CallBegin
83
+ | CallArgFragment
84
+ | CallResolved
85
+ | Metadata
86
+ | Finished
87
+ | ParseFault
88
+ )
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Tool-call argument accumulator
93
+ # ---------------------------------------------------------------------------
94
+
95
+
96
+ @dataclass(slots=True)
97
+ class _ToolBuffer:
98
+ """Accumulates incremental argument fragments for one tool call."""
99
+
100
+ index: int
101
+ call_id: str
102
+ name: str
103
+ fragments: list[str] = field(default_factory=list)
104
+
105
+ @property
106
+ def arguments_json(self) -> str:
107
+ return "".join(self.fragments)
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Decoder protocol
112
+ # ---------------------------------------------------------------------------
113
+
114
+
115
+ class StreamDecoder(Protocol):
116
+ """Common interface implemented by both API-specific decoders."""
117
+
118
+ def decode(self, event_name: str, data: dict[str, Any]) -> list[ParsedSignal]:
119
+ """Process one parsed SSE payload and return any resulting signals."""
120
+ ...
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # Chat Completions decoder
125
+ # ---------------------------------------------------------------------------
126
+
127
+
128
+ def _failure_text(code: str | None, message: str | None) -> str:
129
+ """Human-readable text for a provider-reported stream failure."""
130
+ if message:
131
+ return message
132
+ if code:
133
+ return f"Response failed: {code}"
134
+ return "Provider returned an error"
135
+
136
+
137
+ class ChatDecoder:
138
+ """Decodes Chat Completions streaming chunks into parsed signals."""
139
+
140
+ def __init__(self) -> None:
141
+ self._tools: dict[int, _ToolBuffer] = {}
142
+
143
+ def decode(self, event_name: str, data: dict[str, Any]) -> list[ParsedSignal]:
144
+ # Top-level error object (non-streaming error response)
145
+ failure = stream_failure(event_name, data)
146
+ if failure is not None:
147
+ return [ParseFault(message=_failure_text(*failure))]
148
+
149
+ signals: list[ParsedSignal] = []
150
+
151
+ # Response metadata
152
+ resp_id = data.get("id")
153
+ resp_model = data.get("model")
154
+ if resp_id or resp_model:
155
+ signals.append(Metadata(response_id=resp_id, model=resp_model))
156
+
157
+ choices = data.get("choices")
158
+ if not choices:
159
+ # Usage-only chunk (sent with stream_options.include_usage)
160
+ usage = data.get("usage")
161
+ if usage:
162
+ signals.append(Finished(usage=usage))
163
+ return signals
164
+
165
+ choice = choices[0]
166
+ delta = choice.get("delta", {})
167
+ finish_reason = choice.get("finish_reason")
168
+
169
+ # Reasoning content (extended-thinking models). When one chunk carries
170
+ # both fields, the reasoning precedes the answer it leads to.
171
+ reasoning = delta.get("reasoning_content")
172
+ if reasoning:
173
+ signals.append(ReasoningChunk(text=reasoning))
174
+
175
+ # Text content
176
+ content = delta.get("content")
177
+ if content:
178
+ signals.append(TextChunk(text=content))
179
+
180
+ # Tool call deltas
181
+ for tc in delta.get("tool_calls", []):
182
+ idx = tc.get("index", 0)
183
+ tc_id = tc.get("id")
184
+ func = tc.get("function", {})
185
+ name = func.get("name")
186
+ args_fragment = func.get("arguments", "")
187
+
188
+ if tc_id and name:
189
+ # First chunk — open a new tool buffer
190
+ buf = _ToolBuffer(index=idx, call_id=tc_id, name=name)
191
+ self._tools[idx] = buf
192
+ signals.append(CallBegin(index=idx, call_id=tc_id, name=name))
193
+ if args_fragment:
194
+ buf.fragments.append(args_fragment)
195
+ signals.append(CallArgFragment(index=idx, fragment=args_fragment))
196
+ elif args_fragment and idx in self._tools:
197
+ # Continuation — accumulate arguments
198
+ self._tools[idx].fragments.append(args_fragment)
199
+ signals.append(CallArgFragment(index=idx, fragment=args_fragment))
200
+
201
+ # Stream completion
202
+ if finish_reason is not None:
203
+ # Resolve any pending tool calls
204
+ for buf in self._tools.values():
205
+ signals.append(CallResolved(
206
+ index=buf.index,
207
+ call_id=buf.call_id,
208
+ name=buf.name,
209
+ arguments_json=buf.arguments_json,
210
+ ))
211
+ self._tools.clear()
212
+
213
+ usage = data.get("usage")
214
+ signals.append(Finished(finish_reason=finish_reason, usage=usage))
215
+
216
+ return signals
217
+
218
+
219
+ # ---------------------------------------------------------------------------
220
+ # Responses API decoder
221
+ # ---------------------------------------------------------------------------
222
+
223
+
224
+ class ResponsesDecoder:
225
+ """Decodes Responses API streaming events into parsed signals."""
226
+
227
+ def __init__(self) -> None:
228
+ self._tools: dict[int, _ToolBuffer] = {}
229
+ self._reasoned = False
230
+
231
+ def _resolve(self, idx: int, final: object) -> list[ParsedSignal]:
232
+ """Close the call at *idx*. A non-empty final string is authoritative;"""
233
+ buf = self._tools.pop(idx, None)
234
+ if buf is None:
235
+ return []
236
+ arguments = final if isinstance(final, str) and final else buf.arguments_json
237
+ return [CallResolved(
238
+ index=buf.index,
239
+ call_id=buf.call_id,
240
+ name=buf.name,
241
+ arguments_json=arguments,
242
+ )]
243
+
244
+ def decode(self, event_name: str, data: dict[str, Any]) -> list[ParsedSignal]:
245
+ signals: list[ParsedSignal] = []
246
+
247
+ match event_name:
248
+ # --- lifecycle ----------------------------------------------------
249
+ case "response.created":
250
+ resp = data.get("response", {})
251
+ signals.append(Metadata(
252
+ response_id=resp.get("id"),
253
+ model=resp.get("model"),
254
+ ))
255
+
256
+ # --- text content -------------------------------------------------
257
+ case "response.output_text.delta":
258
+ delta = data.get("delta", "")
259
+ if delta:
260
+ signals.append(TextChunk(text=delta))
261
+
262
+ # --- reasoning content -------------------------------------------
263
+ case "response.reasoning_summary_text.delta":
264
+ delta = data.get("delta", "")
265
+ if delta:
266
+ self._reasoned = True
267
+ signals.append(ReasoningChunk(text=delta))
268
+
269
+ case "response.reasoning_summary_part.done":
270
+ # Summary parts are separate paragraphs.
271
+ if self._reasoned:
272
+ signals.append(ReasoningChunk(text="\n\n"))
273
+
274
+ # --- tool calls ---------------------------------------------------
275
+ case "response.output_item.added":
276
+ item = data.get("item", {})
277
+ idx = data.get("output_index", 0)
278
+ if item.get("type") == "function_call":
279
+ call_id = item.get("call_id", "")
280
+ fn_name = item.get("name", "")
281
+ self._tools[idx] = _ToolBuffer(
282
+ index=idx, call_id=call_id, name=fn_name,
283
+ )
284
+ signals.append(CallBegin(index=idx, call_id=call_id, name=fn_name))
285
+
286
+ case "response.function_call_arguments.delta":
287
+ idx = data.get("output_index", 0)
288
+ delta = data.get("delta", "")
289
+ if delta and idx in self._tools:
290
+ self._tools[idx].fragments.append(delta)
291
+ signals.append(CallArgFragment(index=idx, fragment=delta))
292
+
293
+ case "response.function_call_arguments.done":
294
+ signals += self._resolve(data.get("output_index", 0), data.get("arguments"))
295
+
296
+ case "response.output_item.done":
297
+ # Some servers finish a call here without an arguments `done`.
298
+ item = data.get("item", {})
299
+ if item.get("type") == "function_call":
300
+ signals += self._resolve(data.get("output_index", 0), item.get("arguments"))
301
+
302
+ # --- completion ---------------------------------------------------
303
+ case "response.completed":
304
+ for idx in sorted(self._tools):
305
+ signals += self._resolve(idx, None)
306
+ resp = data.get("response", {})
307
+ usage = resp.get("usage")
308
+ status = resp.get("status", "completed")
309
+ reason = "stop" if status == "completed" else status
310
+ signals.append(Finished(finish_reason=reason, usage=usage))
311
+
312
+ # --- errors -------------------------------------------------------
313
+ case "error" | "response.failed":
314
+ failure = stream_failure(event_name, data) or (None, None)
315
+ signals.append(ParseFault(message=_failure_text(*failure)))
316
+
317
+ return signals
@@ -0,0 +1,226 @@
1
+ """Request payload construction for Chat Completions and Responses APIs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Sequence
7
+ from typing import Any
8
+
9
+ from rlmagent.harness.contracts.tooling import ToolSpec
10
+ from rlmagent.harness.contracts.transcript import (
11
+ CallBlock,
12
+ ExtensionEntry,
13
+ ForkSummaryEntry,
14
+ HumanEntry,
15
+ ImageSegment,
16
+ ModelEntry,
17
+ PruneSummaryEntry,
18
+ ShellResultEntry,
19
+ TextSegment,
20
+ ToolOutcomeEntry,
21
+ TranscriptEntry,
22
+ entry_to_human,
23
+ )
24
+ from rlmagent.model.correlation import wire_call_id
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Tool schema conversion
28
+ # ---------------------------------------------------------------------------
29
+
30
+
31
+ def _chat_function_schema(tool: ToolSpec) -> dict[str, Any]:
32
+ """Convert a ``ToolSpec`` to a Chat Completions function tool definition."""
33
+ return {
34
+ "type": "function",
35
+ "function": {
36
+ "name": tool.name,
37
+ "description": tool.description,
38
+ "parameters": dict(tool.parameters),
39
+ },
40
+ }
41
+
42
+
43
+ def _responses_function_schema(tool: ToolSpec) -> dict[str, Any]:
44
+ """Convert a ``ToolSpec`` to a Responses API function tool definition."""
45
+ return {
46
+ "type": "function",
47
+ "name": tool.name,
48
+ "description": tool.description,
49
+ "parameters": dict(tool.parameters),
50
+ }
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Content helpers
55
+ # ---------------------------------------------------------------------------
56
+
57
+
58
+ def _user_blocks(entry: HumanEntry) -> str | list[dict[str, Any]]:
59
+ """Convert user content to the provider content format."""
60
+ if isinstance(entry.content, str):
61
+ return entry.content
62
+ parts: list[dict[str, Any]] = []
63
+ for seg in entry.content:
64
+ if isinstance(seg, TextSegment):
65
+ parts.append({"type": "text", "text": seg.text})
66
+ elif isinstance(seg, ImageSegment):
67
+ parts.append({
68
+ "type": "image_url",
69
+ "image_url": {"url": f"data:{seg.mime_type};base64,{seg.data}"},
70
+ })
71
+ return parts or ""
72
+
73
+
74
+ def _serialize_call(call: CallBlock) -> dict[str, Any]:
75
+ """Serialise a ``CallBlock`` to the Chat Completions tool_calls format."""
76
+ return {
77
+ "id": wire_call_id(call.id),
78
+ "type": "function",
79
+ "function": {
80
+ "name": call.name,
81
+ "arguments": json.dumps(call.arguments),
82
+ },
83
+ }
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Chat Completions message conversion
88
+ # ---------------------------------------------------------------------------
89
+
90
+
91
+ def _chat_assistant_msg(entry: ModelEntry) -> dict[str, Any]:
92
+ """Convert a ``ModelEntry`` to a Chat Completions assistant message."""
93
+ msg: dict[str, Any] = {"role": "assistant"}
94
+ text_parts = [b.text for b in entry.content if isinstance(b, TextSegment)]
95
+ msg["content"] = "".join(text_parts) or None
96
+ calls = [_serialize_call(b) for b in entry.content if isinstance(b, CallBlock)]
97
+ if calls:
98
+ msg["tool_calls"] = calls
99
+ return msg
100
+
101
+
102
+ def _chat_tool_result_msg(entry: ToolOutcomeEntry) -> dict[str, Any]:
103
+ """Convert a ``ToolOutcomeEntry`` to a Chat Completions tool message."""
104
+ return {
105
+ "role": "tool",
106
+ "tool_call_id": wire_call_id(entry.tool_call_id),
107
+ "content": entry.text or "",
108
+ }
109
+
110
+
111
+ def _to_chat_message(entry: TranscriptEntry) -> dict[str, Any] | None:
112
+ """Convert a single transcript entry to a Chat Completions message dict."""
113
+ if isinstance(entry, HumanEntry):
114
+ return {"role": "user", "content": _user_blocks(entry)}
115
+ if isinstance(entry, ModelEntry):
116
+ return _chat_assistant_msg(entry)
117
+ if isinstance(entry, ToolOutcomeEntry):
118
+ return _chat_tool_result_msg(entry)
119
+ if isinstance(entry, (ExtensionEntry, ShellResultEntry, PruneSummaryEntry, ForkSummaryEntry)):
120
+ return {"role": "user", "content": _user_blocks(entry_to_human(entry))}
121
+ return None
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Responses API message conversion
126
+ # ---------------------------------------------------------------------------
127
+
128
+
129
+ def _to_responses_items(entry: TranscriptEntry) -> list[dict[str, Any]]:
130
+ """Convert a single transcript entry to Responses API input items."""
131
+ if isinstance(entry, HumanEntry):
132
+ return [{"role": "user", "content": _user_blocks(entry)}]
133
+
134
+ if isinstance(entry, ModelEntry):
135
+ items: list[dict[str, Any]] = []
136
+ text = entry.text
137
+ if text:
138
+ items.append({
139
+ "role": "assistant",
140
+ "content": [{"type": "output_text", "text": text}],
141
+ })
142
+ for call in entry.tool_calls:
143
+ items.append({
144
+ "type": "function_call",
145
+ "call_id": wire_call_id(call.id),
146
+ "name": call.name,
147
+ "arguments": json.dumps(call.arguments),
148
+ })
149
+ return items
150
+
151
+ if isinstance(entry, ToolOutcomeEntry):
152
+ return [{
153
+ "type": "function_call_output",
154
+ "call_id": wire_call_id(entry.tool_call_id),
155
+ "output": entry.text or "",
156
+ }]
157
+
158
+ if isinstance(entry, (ExtensionEntry, ShellResultEntry, PruneSummaryEntry, ForkSummaryEntry)):
159
+ return [{"role": "user", "content": _user_blocks(entry_to_human(entry))}]
160
+
161
+ return []
162
+
163
+
164
+ # ---------------------------------------------------------------------------
165
+ # Payload assembly
166
+ # ---------------------------------------------------------------------------
167
+
168
+
169
+ def build_chat_payload(
170
+ *,
171
+ model: str,
172
+ system: str,
173
+ messages: Sequence[TranscriptEntry],
174
+ tools: Sequence[ToolSpec],
175
+ max_tokens: int | None = None,
176
+ extra: dict[str, Any] | None = None,
177
+ ) -> dict[str, Any]:
178
+ """Assemble a complete Chat Completions request body."""
179
+ chat_msgs: list[dict[str, Any]] = [{"role": "system", "content": system}]
180
+ for entry in messages:
181
+ msg = _to_chat_message(entry)
182
+ if msg is not None:
183
+ chat_msgs.append(msg)
184
+
185
+ body: dict[str, Any] = {
186
+ "model": model,
187
+ "stream": True,
188
+ "stream_options": {"include_usage": True},
189
+ "messages": chat_msgs,
190
+ }
191
+ if tools:
192
+ body["tools"] = [_chat_function_schema(t) for t in tools]
193
+ if max_tokens is not None:
194
+ body["max_tokens"] = max_tokens
195
+ if extra:
196
+ body.update(extra)
197
+ return body
198
+
199
+
200
+ def build_responses_payload(
201
+ *,
202
+ model: str,
203
+ system: str,
204
+ messages: Sequence[TranscriptEntry],
205
+ tools: Sequence[ToolSpec],
206
+ max_tokens: int | None = None,
207
+ extra: dict[str, Any] | None = None,
208
+ ) -> dict[str, Any]:
209
+ """Assemble a complete Responses API request body."""
210
+ items: list[dict[str, Any]] = []
211
+ for entry in messages:
212
+ items.extend(_to_responses_items(entry))
213
+
214
+ body: dict[str, Any] = {
215
+ "model": model,
216
+ "stream": True,
217
+ "instructions": system,
218
+ "input": items,
219
+ }
220
+ if tools:
221
+ body["tools"] = [_responses_function_schema(t) for t in tools]
222
+ if max_tokens is not None:
223
+ body["max_output_tokens"] = max_tokens
224
+ if extra:
225
+ body.update(extra)
226
+ return body
@@ -0,0 +1,127 @@
1
+ """Streaming HTTP transport for OpenAI-compatible API endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator, Callable
6
+
7
+ import httpx
8
+
9
+ from rlmagent.harness.contracts.tooling import CancelToken
10
+ from rlmagent.model._oai.helpers import ServerSentEvent, parse_retry_after
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Retriable status codes
14
+ # ---------------------------------------------------------------------------
15
+
16
+ _RETRIABLE_CODES = frozenset({408, 409, 425, 429})
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Error types
21
+ # ---------------------------------------------------------------------------
22
+
23
+
24
+
25
+ class MalformedPayload(ValueError):
26
+ """Raised when an SSE data line cannot be decoded as a valid JSON object."""
27
+
28
+ _TRUNCATE = 200
29
+
30
+ def __init__(self, raw: str) -> None:
31
+ self.raw = raw
32
+ preview = raw[:self._TRUNCATE] + "…" if len(raw) > self._TRUNCATE else raw
33
+ super().__init__(f"Invalid JSON payload: {preview}")
34
+
35
+
36
+ class HttpStreamError(Exception):
37
+ """Raised when a streaming HTTP request receives a non-200 status."""
38
+
39
+ def __init__(
40
+ self,
41
+ status: int,
42
+ body: str,
43
+ *,
44
+ retry_after: float | None = None,
45
+ ) -> None:
46
+ self.status = status
47
+ self.body = body
48
+ self.retry_after_seconds = retry_after
49
+ super().__init__(f"HTTP {status}")
50
+
51
+ @property
52
+ def retriable(self) -> bool:
53
+ """Return whether this status code warrants an automatic reattempt."""
54
+ return self.status in _RETRIABLE_CODES or self.status >= 500
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # SSE stream reader
59
+ # ---------------------------------------------------------------------------
60
+
61
+
62
+ async def _read_sse(response: httpx.Response) -> AsyncIterator[ServerSentEvent]:
63
+ """Parse an HTTP response body as a Server-Sent Events stream."""
64
+ event_type = ""
65
+ data_lines: list[str] = []
66
+
67
+ async for raw_line in response.aiter_lines():
68
+ line = raw_line.rstrip("\r\n")
69
+
70
+ if not line:
71
+ # Empty line — dispatch the accumulated event
72
+ if data_lines:
73
+ payload = "\n".join(data_lines)
74
+ if payload == "[DONE]":
75
+ return
76
+ yield ServerSentEvent(event=event_type, data=payload)
77
+ event_type = ""
78
+ data_lines = []
79
+ continue
80
+
81
+ if line.startswith(":"):
82
+ continue # comment
83
+
84
+ if line.startswith("event:"):
85
+ event_type = line[6:].strip()
86
+ elif line.startswith("data:"):
87
+ data_lines.append(line[5:].lstrip())
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Public streaming request
92
+ # ---------------------------------------------------------------------------
93
+
94
+
95
+ async def open_event_stream(
96
+ client: httpx.AsyncClient,
97
+ url: str,
98
+ payload: dict,
99
+ headers: dict[str, str],
100
+ *,
101
+ signal: CancelToken | None = None,
102
+ on_accept: Callable[[httpx.Response], None] | None = None,
103
+ ) -> AsyncIterator[ServerSentEvent]:
104
+ """Execute a streaming POST and yield parsed SSE events."""
105
+ from rlmagent.agents.trace import correlate_headers
106
+ async with client.stream(
107
+ "POST",
108
+ url,
109
+ json=payload,
110
+ headers=correlate_headers(headers),
111
+ ) as response:
112
+ if response.status_code != 200:
113
+ body = (await response.aread()).decode("utf-8", errors="replace")
114
+ raise HttpStreamError(
115
+ response.status_code,
116
+ body,
117
+ retry_after=parse_retry_after(
118
+ response.headers.get("retry-after")
119
+ ),
120
+ )
121
+ if on_accept is not None:
122
+ on_accept(response)
123
+
124
+ async for sse in _read_sse(response):
125
+ if signal is not None and signal.is_cancelled():
126
+ return
127
+ yield sse