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
rlmagent/model/claude.py
ADDED
|
@@ -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()
|