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,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
|