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.
- rlmagent/__init__.py +1 -0
- rlmagent/__main__.py +8 -0
- rlmagent/acp/__init__.py +1 -0
- rlmagent/acp/runtime.py +71 -0
- rlmagent/acp/server.py +598 -0
- rlmagent/acp/updates.py +128 -0
- rlmagent/agentnet.py +237 -0
- rlmagent/agents/__init__.py +1 -0
- rlmagent/agents/approval.py +90 -0
- rlmagent/agents/bridge.py +109 -0
- rlmagent/agents/budget.py +212 -0
- rlmagent/agents/child_handles.py +46 -0
- rlmagent/agents/child_mailbox.py +30 -0
- rlmagent/agents/kernel_api.py +156 -0
- rlmagent/agents/output.py +34 -0
- rlmagent/agents/patch.py +107 -0
- rlmagent/agents/prompts.py +156 -0
- rlmagent/agents/trace.py +290 -0
- rlmagent/agents/tree.py +682 -0
- rlmagent/agents/wasm_api.py +105 -0
- rlmagent/agents/workspace.py +357 -0
- rlmagent/autopilot.py +447 -0
- rlmagent/backend.py +946 -0
- rlmagent/backend_context.py +74 -0
- rlmagent/cli/__init__.py +5 -0
- rlmagent/cli/config_cmd.py +177 -0
- rlmagent/cli/health.py +156 -0
- rlmagent/cli/main.py +1194 -0
- rlmagent/cli/update.py +188 -0
- rlmagent/clipboard.py +63 -0
- rlmagent/config/__init__.py +37 -0
- rlmagent/config/config.py +297 -0
- rlmagent/config/loader.py +119 -0
- rlmagent/config/prompts.py +124 -0
- rlmagent/config/secure_input.py +213 -0
- rlmagent/config/store.py +275 -0
- rlmagent/context/__init__.py +47 -0
- rlmagent/context/budget.py +557 -0
- rlmagent/contract.py +311 -0
- rlmagent/contract_run.py +232 -0
- rlmagent/conversation.py +2547 -0
- rlmagent/cron.py +77 -0
- rlmagent/directives.py +893 -0
- rlmagent/discovery.py +356 -0
- rlmagent/execution_log.py +66 -0
- rlmagent/factory.py +189 -0
- rlmagent/factory_graph.py +413 -0
- rlmagent/harness/__init__.py +63 -0
- rlmagent/harness/contracts/__init__.py +1 -0
- rlmagent/harness/contracts/stream.py +129 -0
- rlmagent/harness/contracts/tooling.py +127 -0
- rlmagent/harness/contracts/transcript/__init__.py +68 -0
- rlmagent/harness/contracts/transcript/_base.py +29 -0
- rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
- rlmagent/harness/contracts/transcript/entries.py +180 -0
- rlmagent/harness/contracts/transcript/segments.py +64 -0
- rlmagent/harness/contracts/values.py +6 -0
- rlmagent/harness/driver.py +397 -0
- rlmagent/harness/engine.py +489 -0
- rlmagent/harness/mending.py +153 -0
- rlmagent/harness/provider/__init__.py +1 -0
- rlmagent/harness/provider/base.py +27 -0
- rlmagent/harness/provider/wire.py +189 -0
- rlmagent/harness/py.typed +0 -0
- rlmagent/harness/session/__init__.py +5 -0
- rlmagent/harness/session/index.py +218 -0
- rlmagent/harness/session/lease.py +66 -0
- rlmagent/harness/session/records.py +118 -0
- rlmagent/harness/session/replay.py +165 -0
- rlmagent/harness/session/store.py +155 -0
- rlmagent/harness/session/summary.py +46 -0
- rlmagent/hints.py +47 -0
- rlmagent/history.py +92 -0
- rlmagent/hooks/__init__.py +1 -0
- rlmagent/hooks/hooks.py +1 -0
- rlmagent/instructions.py +157 -0
- rlmagent/job_runner.py +117 -0
- rlmagent/jobs.py +365 -0
- rlmagent/kept_agents.py +397 -0
- rlmagent/kernel/__init__.py +5 -0
- rlmagent/kernel/session.py +205 -0
- rlmagent/kernel/wasm.py +169 -0
- rlmagent/logs.py +208 -0
- rlmagent/managed_release.py +368 -0
- rlmagent/mcp.py +411 -0
- rlmagent/mcp_oauth.py +213 -0
- rlmagent/model/__init__.py +29 -0
- rlmagent/model/_claude/__init__.py +1 -0
- rlmagent/model/_claude/courier.py +117 -0
- rlmagent/model/_claude/emitter.py +378 -0
- rlmagent/model/_oai/__init__.py +1 -0
- rlmagent/model/_oai/helpers.py +256 -0
- rlmagent/model/_oai/normalize.py +263 -0
- rlmagent/model/_oai/parsers.py +317 -0
- rlmagent/model/_oai/payloads.py +226 -0
- rlmagent/model/_oai/transport.py +127 -0
- rlmagent/model/claude.py +552 -0
- rlmagent/model/copilot.py +142 -0
- rlmagent/model/correlation.py +21 -0
- rlmagent/model/deadline.py +19 -0
- rlmagent/model/discovery.py +181 -0
- rlmagent/model/external/__init__.py +11 -0
- rlmagent/model/external/acp.py +202 -0
- rlmagent/model/external/acp_install.py +109 -0
- rlmagent/model/external/cli.py +248 -0
- rlmagent/model/external/textcalls.py +57 -0
- rlmagent/model/failover.py +129 -0
- rlmagent/model/google.py +199 -0
- rlmagent/model/limits.py +46 -0
- rlmagent/model/oai_compatible.py +414 -0
- rlmagent/model/preferences.py +56 -0
- rlmagent/model/py.typed +0 -0
- rlmagent/model/registered_oauth.py +195 -0
- rlmagent/model/scripted.py +55 -0
- rlmagent/model/settings.py +271 -0
- rlmagent/model/subscription.py +398 -0
- rlmagent/model/token_bounds.py +15 -0
- rlmagent/model/transport/__init__.py +1 -0
- rlmagent/model/transport/backoff.py +87 -0
- rlmagent/model/transport/client.py +87 -0
- rlmagent/model/transport/faults.py +65 -0
- rlmagent/model/transport/responses_socket.py +223 -0
- rlmagent/model/vertex.py +93 -0
- rlmagent/network.py +123 -0
- rlmagent/packages.py +246 -0
- rlmagent/planning.py +84 -0
- rlmagent/playbook/__init__.py +1 -0
- rlmagent/playbook/apply.py +106 -0
- rlmagent/playbook/digest.py +63 -0
- rlmagent/playbook/improver.py +272 -0
- rlmagent/playbook/plan.py +99 -0
- rlmagent/playbook/store.py +67 -0
- rlmagent/plugins.py +327 -0
- rlmagent/policy.py +105 -0
- rlmagent/prompt_refs.py +57 -0
- rlmagent/prompts.py +331 -0
- rlmagent/prune.py +60 -0
- rlmagent/py.typed +0 -0
- rlmagent/reasoning.py +135 -0
- rlmagent/refinement/__init__.py +1 -0
- rlmagent/refinement/controller.py +448 -0
- rlmagent/refinement/engine.py +220 -0
- rlmagent/refinement/evidence.py +131 -0
- rlmagent/refinement/scoped.py +90 -0
- rlmagent/refinement/store.py +193 -0
- rlmagent/refresh.py +202 -0
- rlmagent/routing.py +52 -0
- rlmagent/runtime.py +575 -0
- rlmagent/safety.py +267 -0
- rlmagent/session_html.py +57 -0
- rlmagent/session_import.py +52 -0
- rlmagent/session_share.py +55 -0
- rlmagent/sessions.py +249 -0
- rlmagent/shell_sandbox.py +88 -0
- rlmagent/skillset.py +320 -0
- rlmagent/steering.py +148 -0
- rlmagent/stops.py +62 -0
- rlmagent/subscriptions.py +219 -0
- rlmagent/tasks.py +98 -0
- rlmagent/toolhost.py +310 -0
- rlmagent/tools/__init__.py +56 -0
- rlmagent/tools/_shared.py +210 -0
- rlmagent/tools/files.py +462 -0
- rlmagent/tools/kernel.py +93 -0
- rlmagent/tools/patch_tool.py +68 -0
- rlmagent/tools/search.py +188 -0
- rlmagent/trust.py +383 -0
- rlmagent/tui/__init__.py +1 -0
- rlmagent/tui/adapter.py +506 -0
- rlmagent/tui/app.py +711 -0
- rlmagent/tui/approval_screen.py +57 -0
- rlmagent/tui/choice_screen.py +47 -0
- rlmagent/tui/completions.py +116 -0
- rlmagent/tui/components.py +25 -0
- rlmagent/tui/composer.py +40 -0
- rlmagent/tui/factory_view.py +86 -0
- rlmagent/tui/inspect.py +193 -0
- rlmagent/tui/messages.py +215 -0
- rlmagent/tui/model_screen.py +307 -0
- rlmagent/tui/prompt.py +98 -0
- rlmagent/tui/question_screen.py +52 -0
- rlmagent/tui/replay.py +141 -0
- rlmagent/tui/rlm_agent.tcss +405 -0
- rlmagent/tui/search_box.py +91 -0
- rlmagent/tui/sidebar.py +182 -0
- rlmagent/tui/status.py +49 -0
- rlmagent/tui/steps.py +283 -0
- rlmagent/tui/suggest.py +778 -0
- rlmagent/tui/transcript.py +172 -0
- rlmagent/tui/trust_dialog.py +58 -0
- rlmagent/tui/worker_attach.py +141 -0
- rlmagent/ui/__init__.py +1 -0
- rlmagent/ui/render.py +211 -0
- rlmagent/undo.py +109 -0
- rlmagent/utils/__init__.py +1 -0
- rlmagent/websearch.py +75 -0
- rlmagent/worker.py +1275 -0
- rlmagent/worker_service.py +203 -0
- rlmness_cli-0.2.0.dist-info/METADATA +882 -0
- rlmness_cli-0.2.0.dist-info/RECORD +203 -0
- rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
- rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|