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,552 @@
1
+ """Anthropic Messages API provider adapter for the rlm-agent agent harness."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import uuid
7
+ from collections.abc import AsyncIterator, Sequence
8
+ from contextlib import aclosing
9
+ from dataclasses import replace
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+ from rlmagent.harness.contracts.tooling import CancelToken, ToolSpec
15
+ from rlmagent.harness.contracts.transcript import (
16
+ CallBlock,
17
+ ExtensionEntry,
18
+ ForkSummaryEntry,
19
+ HumanEntry,
20
+ ImageSegment,
21
+ ModelEntry,
22
+ PruneSummaryEntry,
23
+ ShellResultEntry,
24
+ TextSegment,
25
+ ThoughtSegment,
26
+ ToolOutcomeEntry,
27
+ TranscriptEntry,
28
+ entry_to_human,
29
+ )
30
+ from rlmagent.harness.provider.wire import StreamFaultEvent, StreamOpenEvent, WireEvent
31
+ from rlmagent.model._claude.courier import ApiRejection, relay_sse
32
+ from rlmagent.model._claude.emitter import ResponseMachine
33
+ from rlmagent.model.correlation import wire_call_id
34
+ from rlmagent.model.settings import AnthropicProfile, Credential, ReasoningPolicy
35
+ from rlmagent.model.transport.backoff import RetryBudget, build_retry_event, compute_delay, pause_for_retry
36
+ from rlmagent.model.transport.client import build_async_client
37
+ from rlmagent.model.transport.faults import format_http_error
38
+
39
+ _API_VERSION = "2023-06-01"
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Request body construction
44
+ # ---------------------------------------------------------------------------
45
+
46
+
47
+ def _user_content(entry: HumanEntry) -> list[dict[str, Any]]:
48
+ """Convert a ``HumanEntry`` to Anthropic content blocks."""
49
+ if isinstance(entry.content, str):
50
+ return [{"type": "text", "text": entry.content}]
51
+ parts: list[dict[str, Any]] = []
52
+ for seg in entry.content:
53
+ if isinstance(seg, TextSegment):
54
+ parts.append({"type": "text", "text": seg.text})
55
+ elif isinstance(seg, ImageSegment):
56
+ parts.append({
57
+ "type": "image",
58
+ "source": {
59
+ "type": "base64",
60
+ "media_type": seg.mime_type,
61
+ "data": seg.data,
62
+ },
63
+ })
64
+ return parts or [{"type": "text", "text": ""}]
65
+
66
+
67
+ def _assistant_blocks(entry: ModelEntry) -> list[dict[str, Any]]:
68
+ """Convert a ``ModelEntry`` to Anthropic assistant content blocks."""
69
+ blocks: list[dict[str, Any]] = []
70
+ # Reasoning signatures are opaque state owned by the vendor that minted
71
+ # them; replaying another vendor's as Anthropic thinking fails validation.
72
+ own_reasoning = entry.api == "messages"
73
+ for seg in entry.content:
74
+ if isinstance(seg, ThoughtSegment):
75
+ if not (own_reasoning and seg.thinking_signature):
76
+ continue
77
+ if seg.redacted:
78
+ blocks.append({"type": "redacted_thinking", "data": seg.thinking_signature})
79
+ else:
80
+ blocks.append({
81
+ "type": "thinking",
82
+ "thinking": seg.thinking,
83
+ "signature": seg.thinking_signature,
84
+ })
85
+ elif isinstance(seg, TextSegment):
86
+ block: dict[str, Any] = {"type": "text", "text": seg.text}
87
+ if seg.text_signature:
88
+ block["signature"] = seg.text_signature
89
+ blocks.append(block)
90
+ elif isinstance(seg, CallBlock):
91
+ blocks.append({
92
+ "type": "tool_use",
93
+ "id": wire_call_id(seg.id),
94
+ "name": seg.name,
95
+ "input": seg.arguments,
96
+ })
97
+ # A turn left empty (e.g. reasoning-only from another vendor) is dropped by
98
+ # the caller: the API rejects empty assistant content.
99
+ return blocks
100
+
101
+
102
+ def _tool_result_block(entry: ToolOutcomeEntry) -> dict[str, Any]:
103
+ """Convert a ``ToolOutcomeEntry`` to an Anthropic tool_result block."""
104
+ block: dict[str, Any] = {
105
+ "type": "tool_result",
106
+ "tool_use_id": wire_call_id(entry.tool_call_id),
107
+ "content": entry.text or "",
108
+ }
109
+ if entry.is_error:
110
+ block["is_error"] = True
111
+ return block
112
+
113
+
114
+ def _convert_entry(entry: TranscriptEntry) -> tuple[str, list[dict[str, Any]]]:
115
+ """Map a transcript entry to ``(anthropic_role, content_blocks)``."""
116
+ if isinstance(entry, HumanEntry):
117
+ return ("user", _user_content(entry))
118
+ if isinstance(entry, ModelEntry):
119
+ return ("assistant", _assistant_blocks(entry))
120
+ if isinstance(entry, ToolOutcomeEntry):
121
+ return ("user", [_tool_result_block(entry)])
122
+ if isinstance(entry, (ExtensionEntry, ShellResultEntry, PruneSummaryEntry, ForkSummaryEntry)):
123
+ return ("user", _user_content(entry_to_human(entry)))
124
+ return ("user", [])
125
+
126
+
127
+ def _compile_messages(entries: Sequence[TranscriptEntry]) -> list[dict[str, Any]]:
128
+ """Build a properly alternating Anthropic message list from transcript entries."""
129
+ result: list[dict[str, Any]] = []
130
+ for entry in entries:
131
+ role, blocks = _convert_entry(entry)
132
+ if not blocks:
133
+ continue
134
+ if result and result[-1]["role"] == role:
135
+ result[-1]["content"].extend(blocks)
136
+ else:
137
+ result.append({"role": role, "content": list(blocks)})
138
+ return result
139
+
140
+
141
+ def _tool_schema(tool: ToolSpec) -> dict[str, Any]:
142
+ """Convert a ``ToolSpec`` to an Anthropic tool definition."""
143
+ return {
144
+ "name": tool.name,
145
+ "description": tool.description,
146
+ "input_schema": dict(tool.parameters),
147
+ }
148
+
149
+
150
+ def _thinking_section(
151
+ policy: ReasoningPolicy, max_tokens: int,
152
+ ) -> dict[str, Any] | None:
153
+ """Build the ``thinking`` parameter for the Messages API."""
154
+ if not policy.enabled:
155
+ return None
156
+ budget = policy.budget_tokens
157
+ if budget is None:
158
+ budget = max(1024, max_tokens - 1024)
159
+ return {"type": "enabled", "budget_tokens": budget}
160
+
161
+
162
+ # ---------------------------------------------------------------------------
163
+ # Prompt caching
164
+ # ---------------------------------------------------------------------------
165
+ #
166
+ # The API allows at most four cache markers and evaluates the cached prefix in
167
+ # tools -> system -> messages order. All four are spent: the last tool, the
168
+ # system prompt, this request's tail, and the previous request's tail. The
169
+ # second message marker exists because the API looks back at most 20 blocks
170
+ # from a marker; a turn with many parallel tool calls appends enough blocks to
171
+ # push the previous cache entry out of a single tail marker's window.
172
+
173
+ _CACHEABLE_BLOCKS = frozenset({"text", "image", "tool_result"})
174
+ _FIRST_PARTY_HOST = "api.anthropic.com"
175
+
176
+
177
+ def resolve_cache_retention(configured: str | None, base_url: str) -> str:
178
+ """Pick the effective cache lifetime for an endpoint."""
179
+ if configured is not None:
180
+ return configured
181
+ host = httpx.URL(base_url).host
182
+ return "short" if host == _FIRST_PARTY_HOST else "none"
183
+
184
+
185
+ def _cache_marker(retention: str) -> dict[str, Any] | None:
186
+ """The ``cache_control`` value for a retention choice; ``None`` disables caching."""
187
+ if retention == "none":
188
+ return None
189
+ if retention == "long":
190
+ return {"type": "ephemeral", "ttl": "1h"}
191
+ return {"type": "ephemeral"}
192
+
193
+
194
+ def _system_field(system: str, marker: dict[str, Any] | None) -> Any:
195
+ """The ``system`` value; a cached prompt must be sent as a marked text block."""
196
+ if marker is None or not system:
197
+ # Caching off keeps the plain-string shape; an empty block with a
198
+ # marker would be rejected outright.
199
+ return system
200
+ return [{"type": "text", "text": system, "cache_control": dict(marker)}]
201
+
202
+
203
+ def _place_message_breakpoints(
204
+ messages: list[dict[str, Any]], marker: dict[str, Any] | None,
205
+ ) -> None:
206
+ """Mark this request's tail and the previous request's tail, in place."""
207
+ if marker is None or not messages:
208
+ return
209
+ positions = {len(messages) - 1}
210
+ earlier = _previous_request_tail(messages)
211
+ if earlier is not None:
212
+ positions.add(earlier)
213
+ for index in positions:
214
+ _mark_block(messages[index], marker)
215
+
216
+
217
+ def _previous_request_tail(messages: list[dict[str, Any]]) -> int | None:
218
+ """Index where the previous request's message list ended."""
219
+ last_reply = next(
220
+ (i for i in range(len(messages) - 1, -1, -1) if messages[i]["role"] == "assistant"),
221
+ None,
222
+ )
223
+ if last_reply is None:
224
+ return None
225
+ return next(
226
+ (i for i in range(last_reply - 1, -1, -1) if messages[i]["role"] == "user"),
227
+ None,
228
+ )
229
+
230
+
231
+ def _mark_block(message: dict[str, Any], marker: dict[str, Any]) -> None:
232
+ """Attach ``marker`` to a user message's final block when that block may carry one."""
233
+ if message.get("role") != "user":
234
+ return
235
+ content = message.get("content")
236
+ if not isinstance(content, list) or not content:
237
+ return
238
+ last = content[-1]
239
+ kind = last.get("type")
240
+ if kind not in _CACHEABLE_BLOCKS:
241
+ return
242
+ # A marker on an empty block is rejected by the API.
243
+ if kind == "text" and not last.get("text"):
244
+ return
245
+ if kind == "tool_result" and not last.get("content"):
246
+ return
247
+ last["cache_control"] = dict(marker)
248
+
249
+
250
+ def _compose_body(
251
+ *,
252
+ model: str,
253
+ system: str,
254
+ messages: Sequence[TranscriptEntry],
255
+ tools: Sequence[ToolSpec],
256
+ max_tokens: int,
257
+ thinking: ReasoningPolicy,
258
+ cache_retention: str = "none",
259
+ ) -> dict[str, Any]:
260
+ """Assemble the complete Messages API request body."""
261
+ marker = _cache_marker(cache_retention)
262
+ compiled = _compile_messages(messages)
263
+ _place_message_breakpoints(compiled, marker)
264
+ body: dict[str, Any] = {
265
+ "model": model,
266
+ "max_tokens": max_tokens,
267
+ "system": _system_field(system, marker),
268
+ "messages": compiled,
269
+ "stream": True,
270
+ }
271
+ if tools:
272
+ body["tools"] = [_tool_schema(t) for t in tools]
273
+ if marker is not None:
274
+ # One marker on the final tool caches the whole schema block.
275
+ body["tools"][-1]["cache_control"] = dict(marker)
276
+ thinking_cfg = _thinking_section(thinking, max_tokens)
277
+ if thinking_cfg is not None:
278
+ body["thinking"] = thinking_cfg
279
+ return body
280
+
281
+
282
+ # Error types the Messages API sends inside an otherwise healthy stream when
283
+ # the condition is temporary. Anything else (auth, invalid request) is final.
284
+ _PASSING_STREAM_ERRORS = frozenset({"api_error", "overloaded_error", "rate_limit_error"})
285
+
286
+
287
+ def _is_passing_stream_error(payload: dict[str, Any]) -> bool:
288
+ """Whether an in-stream ``error`` event is transient and safe to reissue."""
289
+ error = payload.get("error")
290
+ if not isinstance(error, dict):
291
+ return False
292
+ kind = error.get("type")
293
+ return isinstance(kind, str) and kind.lower() in _PASSING_STREAM_ERRORS
294
+
295
+
296
+ # ---------------------------------------------------------------------------
297
+ # Authentication
298
+ # ---------------------------------------------------------------------------
299
+
300
+
301
+ def _auth_headers(cred: Credential) -> dict[str, str]:
302
+ """Build HTTP headers from a credential snapshot."""
303
+ headers: dict[str, str] = {
304
+ "content-type": "application/json",
305
+ "anthropic-version": _API_VERSION,
306
+ }
307
+ if cred.api_key:
308
+ headers["x-api-key"] = cred.api_key
309
+ for key, value in cred.extra_headers:
310
+ headers[key] = value
311
+ return headers
312
+
313
+
314
+ # ---------------------------------------------------------------------------
315
+ # Provider adapter
316
+ # ---------------------------------------------------------------------------
317
+
318
+
319
+ class AnthropicProvider:
320
+ """Streaming model adapter for Anthropic's Messages API."""
321
+
322
+ def __init__(self, profile: AnthropicProfile) -> None:
323
+ self._retry_budget = RetryBudget()
324
+ self._profile = profile
325
+ self._client: httpx.AsyncClient = build_async_client(
326
+ timeout=httpx.Timeout(profile.timeout_seconds),
327
+ )
328
+
329
+ def set_reasoning(self, policy: ReasoningPolicy) -> None:
330
+ """Swap the extended-thinking policy for subsequent requests."""
331
+ self._profile = replace(self._profile, reasoning=policy)
332
+
333
+ async def close(self) -> None:
334
+ """Release the underlying HTTP client resources."""
335
+ await self._client.aclose()
336
+
337
+ # --- ModelProvider protocol ---------------------------------------------
338
+
339
+ def stream_response(
340
+ self,
341
+ *,
342
+ model: str,
343
+ system: str,
344
+ messages: Sequence[TranscriptEntry],
345
+ tools: Sequence[ToolSpec],
346
+ signal: CancelToken | None = None,
347
+ cache_key: str | None = None,
348
+ ) -> AsyncIterator[WireEvent]:
349
+ """Stream wire events for one model round-trip."""
350
+ return self._stream(
351
+ model=model,
352
+ system=system,
353
+ messages=messages,
354
+ tools=tools,
355
+ signal=signal,
356
+ )
357
+
358
+ # --- core streaming loop ------------------------------------------------
359
+
360
+ async def _stream(
361
+ self,
362
+ *,
363
+ model: str,
364
+ system: str,
365
+ messages: Sequence[TranscriptEntry],
366
+ tools: Sequence[ToolSpec],
367
+ signal: CancelToken | None,
368
+ ) -> AsyncIterator[WireEvent]:
369
+ cred = await self._fresh_credential()
370
+ base = cred.base_url.rstrip("/")
371
+ url = f"{base}/v1/messages"
372
+
373
+ body = _compose_body(
374
+ model=model,
375
+ system=system,
376
+ messages=messages,
377
+ tools=tools,
378
+ max_tokens=self._profile.max_tokens,
379
+ thinking=self._profile.reasoning,
380
+ cache_retention=resolve_cache_retention(
381
+ self._profile.cache_retention, cred.base_url,
382
+ ),
383
+ )
384
+
385
+ retry = self._profile.retry
386
+ ceiling = retry.max_retries + 1
387
+ # One key per logical call, kept across its retries, so a proxy in
388
+ # front of the provider can drop a duplicate of a request it served.
389
+ # Opt-in: the providers' own clients do not send it on these
390
+ # endpoints, so nothing shows the providers accept it.
391
+ idempotency_key = (
392
+ uuid.uuid4().hex if (self._profile.idempotency if self._profile.idempotency is not None
393
+ else os.environ.get("RLM_AGENT_IDEMPOTENCY") == "1") else None
394
+ )
395
+ logical_request = idempotency_key or uuid.uuid4().hex
396
+
397
+ for attempt in range(ceiling):
398
+ if signal is not None and signal.is_cancelled():
399
+ yield self._cancelled(model)
400
+ return
401
+
402
+ if attempt > 0 and self._profile.resolver is not None:
403
+ cred = await self._profile.resolver()
404
+
405
+ from rlmagent.agents.trace import correlate_headers
406
+
407
+ headers = correlate_headers(_auth_headers(cred))
408
+ if idempotency_key is not None:
409
+ headers["Idempotency-Key"] = idempotency_key
410
+ headers["X-Stainless-Retry-Count"] = str(attempt)
411
+ headers["X-Rlmagent-Attempt-Id"] = f"{logical_request}:{attempt}"
412
+ from rlmagent.agents.trace import model_attempt
413
+
414
+ model_attempt(attempt=attempt, id=headers["X-Rlmagent-Attempt-Id"],
415
+ transport="http", phase="started")
416
+ machine = ResponseMachine(model=model, provider=self._profile.name)
417
+
418
+ # The stream-open event is held back until real content arrives.
419
+ # Until then nothing has reached the caller, so a transient error
420
+ # event can be retried invisibly; after that it must surface, or
421
+ # visible output and tool calls could be replayed.
422
+ held: list[WireEvent] = []
423
+ committed = False
424
+ passing_failure = False
425
+ failure_kind = ""
426
+ try:
427
+ async with aclosing(relay_sse(
428
+ self._client, url, body, headers, signal=signal,
429
+ )) as events:
430
+ async for event_name, payload in events:
431
+ if (
432
+ event_name == "error"
433
+ and not committed
434
+ and attempt < retry.max_retries
435
+ and _is_passing_stream_error(payload)
436
+ ):
437
+ passing_failure = True
438
+ failure_kind = payload.get("error", {}).get("type", "stream error")
439
+ break
440
+ for wire_event in machine.ingest(event_name, payload):
441
+ if not committed and isinstance(wire_event, StreamOpenEvent):
442
+ held.append(wire_event)
443
+ continue
444
+ if not committed:
445
+ committed = True
446
+ for early in held:
447
+ yield early
448
+ held.clear()
449
+ yield wire_event
450
+
451
+ if not passing_failure:
452
+ for wire_event in [*held, *machine.seal()]:
453
+ yield wire_event
454
+ return
455
+
456
+ delay = compute_delay(attempt, max_delay_seconds=retry.max_delay_seconds)
457
+ yield build_retry_event(
458
+ attempt=attempt, max_retries=retry.max_retries,
459
+ delay_seconds=delay, reason=f"a transient {failure_kind}",
460
+ )
461
+ alive = await pause_for_retry(delay, signal=signal)
462
+ if not alive:
463
+ yield self._cancelled(model)
464
+ return
465
+ continue
466
+
467
+ except ApiRejection as exc:
468
+ if not exc.retriable or attempt >= retry.max_retries or not self._retry_budget.take():
469
+ yield self._rejection_fault(model, exc)
470
+ return
471
+
472
+ delay = compute_delay(
473
+ attempt, max_delay_seconds=retry.max_delay_seconds,
474
+ )
475
+ if exc.wait_hint is not None:
476
+ delay = max(delay, exc.wait_hint)
477
+
478
+ yield build_retry_event(
479
+ attempt=attempt, max_retries=retry.max_retries,
480
+ delay_seconds=delay, reason=f"HTTP {exc.status}",
481
+ )
482
+ alive = await pause_for_retry(delay, signal=signal)
483
+ if not alive:
484
+ yield self._cancelled(model)
485
+ return
486
+
487
+ except (httpx.TimeoutException, httpx.ConnectError, httpx.ReadError) as exc:
488
+ if attempt >= retry.max_retries or not self._retry_budget.take():
489
+ yield self._network_fault(model, exc)
490
+ return
491
+
492
+ delay = compute_delay(
493
+ attempt, max_delay_seconds=retry.max_delay_seconds,
494
+ )
495
+ yield build_retry_event(
496
+ attempt=attempt, max_retries=retry.max_retries,
497
+ delay_seconds=delay, reason="a network error",
498
+ )
499
+ alive = await pause_for_retry(delay, signal=signal)
500
+ if not alive:
501
+ yield self._cancelled(model)
502
+ return
503
+
504
+ # --- credential helpers -------------------------------------------------
505
+
506
+ async def _fresh_credential(self) -> Credential:
507
+ if self._profile.resolver is not None:
508
+ return await self._profile.resolver()
509
+ return self._profile.credential
510
+
511
+ # --- fault builders -----------------------------------------------------
512
+
513
+ def _cancelled(self, model: str) -> StreamFaultEvent:
514
+ entry = ModelEntry(
515
+ model=model,
516
+ provider=self._profile.name,
517
+ api="messages",
518
+ content=[],
519
+ stop_reason="aborted",
520
+ )
521
+ return StreamFaultEvent(reason="aborted", error=entry)
522
+
523
+ def _rejection_fault(self, model: str, exc: ApiRejection) -> StreamFaultEvent:
524
+ msg = format_http_error(
525
+ provider_name=self._profile.name,
526
+ status_code=exc.status,
527
+ body=exc.detail,
528
+ model=model,
529
+ )
530
+ entry = ModelEntry(
531
+ model=model,
532
+ provider=self._profile.name,
533
+ api="messages",
534
+ content=[],
535
+ stop_reason="error",
536
+ error_message=msg,
537
+ )
538
+ return StreamFaultEvent(reason="error", error=entry)
539
+
540
+ def _network_fault(self, model: str, exc: Exception) -> StreamFaultEvent:
541
+ entry = ModelEntry(
542
+ model=model,
543
+ provider=self._profile.name,
544
+ api="messages",
545
+ content=[],
546
+ stop_reason="error",
547
+ error_message=f"{self._profile.name} network error: {exc}",
548
+ )
549
+ return StreamFaultEvent(reason="error", error=entry)
550
+
551
+
552
+ __all__ = ["AnthropicProvider"]
@@ -0,0 +1,142 @@
1
+ """Copilot subscription inference through the official runtime SDK."""
2
+
3
+ import asyncio
4
+ import tempfile
5
+ from contextlib import suppress
6
+ from uuid import uuid4
7
+
8
+ from rlmagent.harness.contracts.transcript import CallBlock, ModelEntry, TextSegment, UsageStats
9
+ from rlmagent.harness.provider.wire import CallCloseEvent, StreamCloseEvent, StreamFaultEvent
10
+ from rlmagent.model.external.textcalls import PROTOCOL, parse_reply, render
11
+
12
+
13
+ class CopilotProvider:
14
+ def __init__(self, client_factory=None):
15
+ self.client_factory = client_factory
16
+
17
+ async def close(self):
18
+ pass
19
+
20
+ async def stream_response(self, *, model, system, messages, tools, signal=None, cache_key=None):
21
+ client, session, task, temporary = None, None, None, None
22
+ try:
23
+ from copilot import CopilotClient
24
+ from copilot.generated.rpc import PermissionDecisionReject
25
+
26
+ coding = any(tool.name == "python" for tool in tools)
27
+ prompt = system + ("\n\n" + PROTOCOL if coding else "") + "\n\n" + render(messages)
28
+ with (
29
+ temporary := tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
30
+ ) as directory:
31
+ client = (self.client_factory or CopilotClient)(
32
+ working_directory=directory, use_logged_in_user=True
33
+ )
34
+ await client.start()
35
+
36
+ def deny(*args, **kwargs):
37
+ return PermissionDecisionReject(feedback="Workspace actions belong to the host")
38
+
39
+ session = await client.create_session(
40
+ model=model,
41
+ available_tools=[],
42
+ tools=[],
43
+ on_permission_request=deny,
44
+ working_directory=directory,
45
+ skip_custom_instructions=True,
46
+ enable_config_discovery=False,
47
+ enable_file_hooks=False,
48
+ enable_host_git_operations=False,
49
+ enable_skills=False,
50
+ enable_session_store=False,
51
+ mcp_servers={},
52
+ enable_session_telemetry=False,
53
+ custom_agents=[],
54
+ streaming=True,
55
+ )
56
+ usage = UsageStats()
57
+
58
+ def event_seen(event):
59
+ kind = getattr(
60
+ getattr(event, "type", None), "value", getattr(event, "type", "")
61
+ )
62
+ if kind == "assistant.usage":
63
+ data = event.data
64
+ usage.input += max(
65
+ 0,
66
+ (getattr(data, "input_tokens", 0) or 0)
67
+ - (getattr(data, "cache_read_tokens", 0) or 0),
68
+ )
69
+ usage.output += getattr(data, "output_tokens", 0) or 0
70
+ usage.cache_read += getattr(data, "cache_read_tokens", 0) or 0
71
+
72
+ session.on(event_seen)
73
+ task = asyncio.create_task(session.send_and_wait(prompt, timeout=120))
74
+ while not task.done():
75
+ if signal is not None and signal.is_cancelled():
76
+ await session.abort()
77
+ task.cancel()
78
+ with suppress(asyncio.CancelledError):
79
+ await task
80
+ yield StreamFaultEvent(
81
+ reason="aborted", error=ModelEntry(stop_reason="aborted")
82
+ )
83
+ return
84
+ await asyncio.wait({task}, timeout=0.05)
85
+ reply = task.result()
86
+ if reply is None or not getattr(reply.data, "content", ""):
87
+ raise ValueError("Copilot returned no answer")
88
+ text, code = (
89
+ parse_reply(reply.data.content) if coding else (reply.data.content, None)
90
+ )
91
+ content = [TextSegment(text=text)] if text else []
92
+ reason = "stop"
93
+ if code is not None:
94
+ call = CallBlock(id=uuid4().hex, name="python", arguments={"code": code})
95
+ content.append(call)
96
+ reason = "toolUse"
97
+ usage.total_tokens = usage.input + usage.output + usage.cache_read
98
+ message = ModelEntry(
99
+ content=content,
100
+ model=model,
101
+ provider="copilot",
102
+ api="copilot-sdk",
103
+ stop_reason=reason,
104
+ usage=usage,
105
+ )
106
+ if code is not None:
107
+ yield CallCloseEvent(
108
+ content_index=len(content) - 1, tool_call=call, partial=message
109
+ )
110
+ yield StreamCloseEvent(reason=reason, message=message)
111
+ except ImportError:
112
+ yield StreamFaultEvent(
113
+ reason="error",
114
+ error=ModelEntry(
115
+ stop_reason="error",
116
+ error_message="Install rlmagent[copilot] and sign in with copilot login",
117
+ ),
118
+ )
119
+ except asyncio.CancelledError:
120
+ raise
121
+ except Exception as error:
122
+ yield StreamFaultEvent(
123
+ reason="error",
124
+ error=ModelEntry(
125
+ stop_reason="error",
126
+ error_message=(f"Copilot request failed ({type(error).__name__}); "
127
+ "check copilot login"),
128
+ ),
129
+ )
130
+ finally:
131
+ if task is not None and not task.done():
132
+ task.cancel()
133
+ with suppress(asyncio.CancelledError, Exception):
134
+ await task
135
+ if session is not None:
136
+ with suppress(Exception):
137
+ await session.disconnect()
138
+ if client is not None:
139
+ with suppress(Exception):
140
+ await client.stop()
141
+ if temporary is not None:
142
+ temporary.cleanup()