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,489 @@
|
|
|
1
|
+
"""Pure portable agent loop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
|
|
8
|
+
|
|
9
|
+
from rlmagent.harness.contracts.stream import (
|
|
10
|
+
AgentEvent,
|
|
11
|
+
MessageEndEvent,
|
|
12
|
+
MessageStartEvent,
|
|
13
|
+
MessageUpdateEvent,
|
|
14
|
+
RetryEvent,
|
|
15
|
+
RunEndEvent,
|
|
16
|
+
RunStartEvent,
|
|
17
|
+
ToolRunEndEvent,
|
|
18
|
+
ToolRunStartEvent,
|
|
19
|
+
ToolRunUpdateEvent,
|
|
20
|
+
TurnEndEvent,
|
|
21
|
+
TurnStartEvent,
|
|
22
|
+
)
|
|
23
|
+
from rlmagent.harness.contracts.tooling import CancelToken, ToolOutcome, ToolSpec
|
|
24
|
+
from rlmagent.harness.contracts.transcript import (
|
|
25
|
+
CallBlock,
|
|
26
|
+
ModelEntry,
|
|
27
|
+
TextSegment,
|
|
28
|
+
ToolOutcomeEntry,
|
|
29
|
+
TranscriptEntry,
|
|
30
|
+
)
|
|
31
|
+
from rlmagent.harness.mending import mend_tool_history
|
|
32
|
+
from rlmagent.harness.provider import wire
|
|
33
|
+
from rlmagent.harness.provider.base import ModelProvider
|
|
34
|
+
|
|
35
|
+
PreToolHook = Callable[[CallBlock], Awaitable[tuple[bool, str | None]]]
|
|
36
|
+
PostToolHook = Callable[
|
|
37
|
+
[CallBlock, ToolOutcome, bool],
|
|
38
|
+
Awaitable[tuple[ToolOutcome, bool]],
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# --- synthetic entries ------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _make_abort_result(text: str) -> ToolOutcome:
|
|
46
|
+
"""Create an error tool outcome with the given message."""
|
|
47
|
+
return ToolOutcome(content=[TextSegment(text=text)])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _make_error_reply(model: str, reason: str) -> ModelEntry:
|
|
51
|
+
"""Create a synthetic assistant entry representing a fault."""
|
|
52
|
+
return ModelEntry(
|
|
53
|
+
model=model,
|
|
54
|
+
content=[],
|
|
55
|
+
stop_reason="error",
|
|
56
|
+
error_message=reason,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# --- provider streaming -----------------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _accepts_cache_key(provider: ModelProvider) -> bool:
|
|
64
|
+
"""Whether ``provider.stream_response`` takes ``cache_key`` (or ``**kwargs``)."""
|
|
65
|
+
try:
|
|
66
|
+
params = inspect.signature(provider.stream_response).parameters.values()
|
|
67
|
+
except (TypeError, ValueError):
|
|
68
|
+
return False
|
|
69
|
+
return any(
|
|
70
|
+
p.name == "cache_key" or p.kind is inspect.Parameter.VAR_KEYWORD for p in params
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _is_empty_failure(entry: TranscriptEntry) -> bool:
|
|
75
|
+
"""A reply that failed before producing anything."""
|
|
76
|
+
return (
|
|
77
|
+
isinstance(entry, ModelEntry)
|
|
78
|
+
and entry.stop_reason in ("error", "aborted")
|
|
79
|
+
and not entry.content
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def _stream_model_reply(
|
|
84
|
+
provider: ModelProvider,
|
|
85
|
+
model: str,
|
|
86
|
+
system: str,
|
|
87
|
+
transcript: list[TranscriptEntry],
|
|
88
|
+
tool_defs: list[ToolSpec],
|
|
89
|
+
token: CancelToken | None,
|
|
90
|
+
cache_key: str | None = None,
|
|
91
|
+
) -> AsyncIterator[AgentEvent]:
|
|
92
|
+
"""Relay wire events from the provider as agent-level message events."""
|
|
93
|
+
# Only forwarded to adapters that declare the parameter, so providers
|
|
94
|
+
# written before it existed keep working unchanged.
|
|
95
|
+
routing = {"cache_key": cache_key} if cache_key and _accepts_cache_key(provider) else {}
|
|
96
|
+
raw: AsyncIterator[wire.WireEvent] = provider.stream_response(
|
|
97
|
+
model=model,
|
|
98
|
+
system=system,
|
|
99
|
+
messages=transcript,
|
|
100
|
+
tools=tool_defs,
|
|
101
|
+
signal=token,
|
|
102
|
+
**routing,
|
|
103
|
+
)
|
|
104
|
+
saw_open = False
|
|
105
|
+
async for evt in raw:
|
|
106
|
+
if isinstance(evt, wire.StreamOpenEvent):
|
|
107
|
+
saw_open = True
|
|
108
|
+
yield MessageStartEvent(message=evt.partial)
|
|
109
|
+
elif isinstance(evt, wire.StreamCloseEvent):
|
|
110
|
+
if not saw_open:
|
|
111
|
+
yield MessageStartEvent(message=evt.message)
|
|
112
|
+
yield MessageEndEvent(message=evt.message)
|
|
113
|
+
elif isinstance(evt, wire.StreamFaultEvent):
|
|
114
|
+
if not saw_open:
|
|
115
|
+
yield MessageStartEvent(message=evt.error)
|
|
116
|
+
yield MessageEndEvent(message=evt.error)
|
|
117
|
+
elif isinstance(evt, wire.SourceRetryEvent):
|
|
118
|
+
yield RetryEvent(
|
|
119
|
+
attempt=evt.attempt,
|
|
120
|
+
max_attempts=evt.max_attempts,
|
|
121
|
+
delay_seconds=evt.delay_seconds,
|
|
122
|
+
message=evt.message,
|
|
123
|
+
)
|
|
124
|
+
else:
|
|
125
|
+
yield MessageUpdateEvent(
|
|
126
|
+
message=evt.partial,
|
|
127
|
+
assistant_message_event=evt.model_dump(),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# --- single tool invocation -------------------------------------------------
|
|
132
|
+
|
|
133
|
+
_JSON_TYPES = {
|
|
134
|
+
"string": lambda v: isinstance(v, str),
|
|
135
|
+
"boolean": lambda v: isinstance(v, bool),
|
|
136
|
+
"integer": lambda v: not isinstance(v, bool) and (
|
|
137
|
+
isinstance(v, int) or (isinstance(v, float) and v == int(v))),
|
|
138
|
+
"number": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool),
|
|
139
|
+
"array": lambda v: isinstance(v, list),
|
|
140
|
+
"object": lambda v: isinstance(v, dict),
|
|
141
|
+
"null": lambda v: v is None,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def argument_problem(schema, arguments) -> str | None:
|
|
146
|
+
"""What is wrong with a call's arguments: missing required ones or wrong top-level types."""
|
|
147
|
+
for name in schema.get("required") or ():
|
|
148
|
+
if name not in arguments:
|
|
149
|
+
return f"missing required argument {name!r}"
|
|
150
|
+
properties = schema.get("properties") or {}
|
|
151
|
+
for name, value in arguments.items():
|
|
152
|
+
declared = (properties.get(name) or {}).get("type") if isinstance(properties, dict) else None
|
|
153
|
+
allowed = declared if isinstance(declared, list) else [declared] if declared else []
|
|
154
|
+
known = [t for t in allowed if t in _JSON_TYPES]
|
|
155
|
+
if known and not any(_JSON_TYPES[t](value) for t in known):
|
|
156
|
+
return f"{name!r} must be {' or '.join(known)}, got {type(value).__name__}"
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def _run_single_tool(
|
|
162
|
+
spec: ToolSpec,
|
|
163
|
+
invocation: CallBlock,
|
|
164
|
+
token: CancelToken | None,
|
|
165
|
+
) -> tuple[ToolOutcome, bool, list[ToolOutcome]]:
|
|
166
|
+
"""Execute one tool, capturing progress snapshots."""
|
|
167
|
+
snapshots: list[ToolOutcome] = []
|
|
168
|
+
alive = True
|
|
169
|
+
|
|
170
|
+
def _on_partial(snapshot: ToolOutcome) -> None:
|
|
171
|
+
if alive:
|
|
172
|
+
snapshots.append(snapshot.model_copy(deep=True))
|
|
173
|
+
|
|
174
|
+
problem = argument_problem(spec.parameters, invocation.arguments)
|
|
175
|
+
if problem:
|
|
176
|
+
return _make_abort_result(f"Invalid arguments for {spec.name}: {problem}"), True, snapshots
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
result = await spec.execute(invocation.id, invocation.arguments, token, _on_partial)
|
|
180
|
+
return result, False, snapshots
|
|
181
|
+
except asyncio.CancelledError:
|
|
182
|
+
raise
|
|
183
|
+
except Exception as exc: # noqa: BLE001 - tools are an isolation boundary
|
|
184
|
+
return _make_abort_result(str(exc)), True, snapshots
|
|
185
|
+
finally:
|
|
186
|
+
alive = False
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# --- tool dispatch pipeline -------------------------------------------------
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def _handle_invocation(
|
|
193
|
+
invocation: CallBlock,
|
|
194
|
+
registry: Mapping[str, ToolSpec],
|
|
195
|
+
token: CancelToken | None,
|
|
196
|
+
pre_hook: PreToolHook | None,
|
|
197
|
+
post_hook: PostToolHook | None,
|
|
198
|
+
) -> AsyncIterator[AgentEvent]:
|
|
199
|
+
"""Run one tool call through the full pre-hook / execute / post-hook pipeline."""
|
|
200
|
+
yield ToolRunStartEvent(
|
|
201
|
+
tool_call_id=invocation.id,
|
|
202
|
+
tool_name=invocation.name,
|
|
203
|
+
args=invocation.arguments,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
rejected = False
|
|
207
|
+
rejection_msg: str | None = None
|
|
208
|
+
if pre_hook is not None:
|
|
209
|
+
rejected, rejection_msg = await pre_hook(invocation)
|
|
210
|
+
|
|
211
|
+
if rejected:
|
|
212
|
+
outcome = _make_abort_result(rejection_msg or "Tool execution was blocked")
|
|
213
|
+
errored = True
|
|
214
|
+
elif token is not None and token.is_cancelled():
|
|
215
|
+
outcome = _make_abort_result("Operation aborted")
|
|
216
|
+
errored = True
|
|
217
|
+
else:
|
|
218
|
+
spec = registry.get(invocation.name)
|
|
219
|
+
if spec is None:
|
|
220
|
+
outcome = _make_abort_result(f"Tool {invocation.name} not found")
|
|
221
|
+
errored = True
|
|
222
|
+
else:
|
|
223
|
+
outcome, errored, snapshots = await _run_single_tool(spec, invocation, token)
|
|
224
|
+
for snap in snapshots:
|
|
225
|
+
yield ToolRunUpdateEvent(
|
|
226
|
+
tool_call_id=invocation.id,
|
|
227
|
+
tool_name=invocation.name,
|
|
228
|
+
args=invocation.arguments,
|
|
229
|
+
partial_result=snap,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
if post_hook is not None:
|
|
233
|
+
outcome, errored = await post_hook(invocation, outcome, errored)
|
|
234
|
+
|
|
235
|
+
yield ToolRunEndEvent(
|
|
236
|
+
tool_call_id=invocation.id,
|
|
237
|
+
tool_name=invocation.name,
|
|
238
|
+
result=outcome,
|
|
239
|
+
is_error=errored,
|
|
240
|
+
)
|
|
241
|
+
record = ToolOutcomeEntry(
|
|
242
|
+
tool_call_id=invocation.id,
|
|
243
|
+
tool_name=invocation.name,
|
|
244
|
+
content=outcome.content,
|
|
245
|
+
details=outcome.details,
|
|
246
|
+
added_tool_names=outcome.added_tool_names,
|
|
247
|
+
is_error=errored,
|
|
248
|
+
)
|
|
249
|
+
yield MessageStartEvent(message=record)
|
|
250
|
+
yield MessageEndEvent(message=record)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# --- parallel / sequential strategy -----------------------------------------
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
async def _gather_events_for(
|
|
257
|
+
invocation: CallBlock,
|
|
258
|
+
registry: Mapping[str, ToolSpec],
|
|
259
|
+
token: CancelToken | None,
|
|
260
|
+
pre_hook: PreToolHook | None,
|
|
261
|
+
post_hook: PostToolHook | None,
|
|
262
|
+
) -> list[AgentEvent]:
|
|
263
|
+
"""Buffer all events from a single tool invocation (used for gather)."""
|
|
264
|
+
buf: list[AgentEvent] = []
|
|
265
|
+
async for ev in _handle_invocation(invocation, registry, token, pre_hook, post_hook):
|
|
266
|
+
buf.append(ev)
|
|
267
|
+
return buf
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _can_parallelize(
|
|
271
|
+
invocations: list[CallBlock],
|
|
272
|
+
registry: Mapping[str, ToolSpec],
|
|
273
|
+
) -> bool:
|
|
274
|
+
"""True when every invocation's tool permits concurrent execution."""
|
|
275
|
+
return all(
|
|
276
|
+
(spec := registry.get(c.name)) is None or spec.parallelism != "sequential"
|
|
277
|
+
for c in invocations
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _cancelled_outcome_events(invocation: CallBlock) -> list[AgentEvent]:
|
|
282
|
+
"""Build the event sequence for a call aborted before execution."""
|
|
283
|
+
abort = _make_abort_result("Operation aborted")
|
|
284
|
+
record = ToolOutcomeEntry(
|
|
285
|
+
tool_call_id=invocation.id,
|
|
286
|
+
tool_name=invocation.name,
|
|
287
|
+
content=abort.content,
|
|
288
|
+
is_error=True,
|
|
289
|
+
)
|
|
290
|
+
return [
|
|
291
|
+
ToolRunStartEvent(
|
|
292
|
+
tool_call_id=invocation.id,
|
|
293
|
+
tool_name=invocation.name,
|
|
294
|
+
args=invocation.arguments,
|
|
295
|
+
),
|
|
296
|
+
ToolRunEndEvent(
|
|
297
|
+
tool_call_id=invocation.id,
|
|
298
|
+
tool_name=invocation.name,
|
|
299
|
+
result=abort,
|
|
300
|
+
is_error=True,
|
|
301
|
+
),
|
|
302
|
+
MessageStartEvent(message=record),
|
|
303
|
+
MessageEndEvent(message=record),
|
|
304
|
+
]
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
async def _execute_calls(
|
|
308
|
+
invocations: list[CallBlock],
|
|
309
|
+
registry: Mapping[str, ToolSpec],
|
|
310
|
+
token: CancelToken | None,
|
|
311
|
+
pre_hook: PreToolHook | None,
|
|
312
|
+
post_hook: PostToolHook | None,
|
|
313
|
+
) -> AsyncIterator[AgentEvent]:
|
|
314
|
+
"""Execute tool calls, choosing parallel or sequential strategy."""
|
|
315
|
+
if len(invocations) > 1 and _can_parallelize(invocations, registry):
|
|
316
|
+
batches = await asyncio.gather(*(
|
|
317
|
+
_gather_events_for(inv, registry, token, pre_hook, post_hook)
|
|
318
|
+
for inv in invocations
|
|
319
|
+
))
|
|
320
|
+
for batch in batches:
|
|
321
|
+
for ev in batch:
|
|
322
|
+
yield ev
|
|
323
|
+
else:
|
|
324
|
+
for inv in invocations:
|
|
325
|
+
if token is not None and token.is_cancelled():
|
|
326
|
+
for ev in _cancelled_outcome_events(inv):
|
|
327
|
+
yield ev
|
|
328
|
+
continue
|
|
329
|
+
async for ev in _handle_invocation(inv, registry, token, pre_hook, post_hook):
|
|
330
|
+
yield ev
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _pick_outcome_entries(events: list[AgentEvent]) -> list[ToolOutcomeEntry]:
|
|
334
|
+
"""Extract ToolOutcomeEntry objects from MessageEndEvent events."""
|
|
335
|
+
return [
|
|
336
|
+
ev.message
|
|
337
|
+
for ev in events
|
|
338
|
+
if isinstance(ev, MessageEndEvent) and isinstance(ev.message, ToolOutcomeEntry)
|
|
339
|
+
]
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# --- main loop --------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
async def run_agent_loop(
|
|
346
|
+
*,
|
|
347
|
+
provider: ModelProvider,
|
|
348
|
+
model: str,
|
|
349
|
+
system: str,
|
|
350
|
+
messages: list[TranscriptEntry],
|
|
351
|
+
tools: list[ToolSpec],
|
|
352
|
+
prompts: Sequence[TranscriptEntry] = (),
|
|
353
|
+
max_turns: int | None = None,
|
|
354
|
+
signal: CancelToken | None = None,
|
|
355
|
+
get_steering_messages: Callable[[], Sequence[TranscriptEntry]] | None = None,
|
|
356
|
+
get_follow_up_messages: Callable[[], Sequence[TranscriptEntry]] | None = None,
|
|
357
|
+
before_tool_call: PreToolHook | None = None,
|
|
358
|
+
after_tool_call: PostToolHook | None = None,
|
|
359
|
+
cache_key: str | None = None,
|
|
360
|
+
) -> AsyncIterator[AgentEvent]:
|
|
361
|
+
"""Run the provider/tool loop and emit portable agent events."""
|
|
362
|
+
emitted: list[TranscriptEntry] = list(prompts)
|
|
363
|
+
if prompts:
|
|
364
|
+
messages.extend(prompts)
|
|
365
|
+
|
|
366
|
+
yield RunStartEvent()
|
|
367
|
+
yield TurnStartEvent()
|
|
368
|
+
for p in prompts:
|
|
369
|
+
yield MessageStartEvent(message=p)
|
|
370
|
+
yield MessageEndEvent(message=p)
|
|
371
|
+
|
|
372
|
+
if max_turns is not None and max_turns < 1:
|
|
373
|
+
err = _make_error_reply(model, "max_turns must be at least 1")
|
|
374
|
+
messages.append(err)
|
|
375
|
+
emitted.append(err)
|
|
376
|
+
yield MessageStartEvent(message=err)
|
|
377
|
+
yield MessageEndEvent(message=err)
|
|
378
|
+
yield TurnEndEvent(message=err)
|
|
379
|
+
yield RunEndEvent(messages=emitted)
|
|
380
|
+
return
|
|
381
|
+
|
|
382
|
+
lookup = {t.name: t for t in tools}
|
|
383
|
+
step = 1
|
|
384
|
+
first = True
|
|
385
|
+
pending_steering = tuple(get_steering_messages() if get_steering_messages else ())
|
|
386
|
+
|
|
387
|
+
while True:
|
|
388
|
+
has_calls = True
|
|
389
|
+
while has_calls or pending_steering:
|
|
390
|
+
# A cancelled run must not begin a *further* turn. The provider
|
|
391
|
+
# stops mid-stream on its own, but a partial reply often carries
|
|
392
|
+
# stop_reason="stop", which would otherwise look like a normal
|
|
393
|
+
# turn and send the loop round again.
|
|
394
|
+
#
|
|
395
|
+
# The first turn is always allowed to proceed: `_execute_calls`
|
|
396
|
+
# emits "aborted" outcomes for pending tool calls, and skipping it
|
|
397
|
+
# would leave dangling tool calls that providers reject.
|
|
398
|
+
if not first and signal is not None and signal.is_cancelled():
|
|
399
|
+
yield RunEndEvent(messages=emitted)
|
|
400
|
+
return
|
|
401
|
+
|
|
402
|
+
if not first:
|
|
403
|
+
yield TurnStartEvent()
|
|
404
|
+
first = False
|
|
405
|
+
|
|
406
|
+
for entry in pending_steering:
|
|
407
|
+
messages.append(entry)
|
|
408
|
+
emitted.append(entry)
|
|
409
|
+
yield MessageStartEvent(message=entry)
|
|
410
|
+
yield MessageEndEvent(message=entry)
|
|
411
|
+
pending_steering = ()
|
|
412
|
+
|
|
413
|
+
if max_turns is not None and step > max_turns:
|
|
414
|
+
err = _make_error_reply(
|
|
415
|
+
model, f"Agent stopped after max_turns={max_turns}"
|
|
416
|
+
)
|
|
417
|
+
messages.append(err)
|
|
418
|
+
emitted.append(err)
|
|
419
|
+
yield MessageStartEvent(message=err)
|
|
420
|
+
yield MessageEndEvent(message=err)
|
|
421
|
+
yield TurnEndEvent(message=err)
|
|
422
|
+
yield RunEndEvent(messages=emitted)
|
|
423
|
+
return
|
|
424
|
+
|
|
425
|
+
assistant: ModelEntry | None = None
|
|
426
|
+
# Last safety net for callers that never mended stored history:
|
|
427
|
+
# the provider sees valid call/result pairing, the transcript the
|
|
428
|
+
# harness owns is left exactly as it is.
|
|
429
|
+
outbound = [
|
|
430
|
+
entry for entry in mend_tool_history(messages).entries
|
|
431
|
+
if not _is_empty_failure(entry)
|
|
432
|
+
]
|
|
433
|
+
async for ev in _stream_model_reply(
|
|
434
|
+
provider, model, system, outbound, tools, signal, cache_key,
|
|
435
|
+
):
|
|
436
|
+
yield ev
|
|
437
|
+
if isinstance(ev, MessageEndEvent) and isinstance(ev.message, ModelEntry):
|
|
438
|
+
assistant = ev.message
|
|
439
|
+
|
|
440
|
+
if assistant is None:
|
|
441
|
+
assistant = _make_error_reply(
|
|
442
|
+
model, "Provider produced no assistant message"
|
|
443
|
+
)
|
|
444
|
+
yield MessageStartEvent(message=assistant)
|
|
445
|
+
yield MessageEndEvent(message=assistant)
|
|
446
|
+
|
|
447
|
+
messages.append(assistant)
|
|
448
|
+
emitted.append(assistant)
|
|
449
|
+
if assistant.stop_reason in {"error", "aborted"}:
|
|
450
|
+
yield TurnEndEvent(message=assistant)
|
|
451
|
+
yield RunEndEvent(messages=emitted)
|
|
452
|
+
return
|
|
453
|
+
|
|
454
|
+
calls = list(assistant.tool_calls)
|
|
455
|
+
has_calls = bool(calls)
|
|
456
|
+
|
|
457
|
+
tool_events: list[AgentEvent] = []
|
|
458
|
+
async for ev in _execute_calls(
|
|
459
|
+
calls, lookup, signal, before_tool_call, after_tool_call,
|
|
460
|
+
):
|
|
461
|
+
yield ev
|
|
462
|
+
tool_events.append(ev)
|
|
463
|
+
|
|
464
|
+
# Tools can ask for the turn to end here. Only an unanimous batch
|
|
465
|
+
# ends it: one tool cannot silence results the model still needs.
|
|
466
|
+
ends = [ev.result for ev in tool_events if isinstance(ev, ToolRunEndEvent)]
|
|
467
|
+
if ends and all(result.terminate is True for result in ends):
|
|
468
|
+
has_calls = False
|
|
469
|
+
|
|
470
|
+
outcomes = _pick_outcome_entries(tool_events)
|
|
471
|
+
for oc in outcomes:
|
|
472
|
+
messages.append(oc)
|
|
473
|
+
emitted.append(oc)
|
|
474
|
+
|
|
475
|
+
yield TurnEndEvent(message=assistant, tool_results=outcomes)
|
|
476
|
+
step += 1
|
|
477
|
+
pending_steering = tuple(
|
|
478
|
+
get_steering_messages() if get_steering_messages else ()
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
followups = tuple(
|
|
482
|
+
get_follow_up_messages() if get_follow_up_messages else ()
|
|
483
|
+
)
|
|
484
|
+
if followups:
|
|
485
|
+
pending_steering = followups
|
|
486
|
+
continue
|
|
487
|
+
break
|
|
488
|
+
|
|
489
|
+
yield RunEndEvent(messages=emitted)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Deterministic repair of malformed tool-call history."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from rlmagent.harness.contracts.transcript import (
|
|
10
|
+
CallBlock,
|
|
11
|
+
ModelEntry,
|
|
12
|
+
TextSegment,
|
|
13
|
+
ToolOutcomeEntry,
|
|
14
|
+
TranscriptEntry,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
INTERRUPTED_RESULT_TEXT = "Tool call interrupted by user"
|
|
18
|
+
|
|
19
|
+
# A call is identified by (index of its assistant entry, 1-based position
|
|
20
|
+
# among that entry's calls); its ideal result slot is entry index + position.
|
|
21
|
+
type _CallSlot = tuple[int, int]
|
|
22
|
+
type _Pick = tuple[int | None, ToolOutcomeEntry]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class HistoryMend:
|
|
27
|
+
"""Repaired history plus counters describing what was changed."""
|
|
28
|
+
|
|
29
|
+
entries: tuple[TranscriptEntry, ...]
|
|
30
|
+
changed: bool = False
|
|
31
|
+
synthesized: int = 0
|
|
32
|
+
dropped_orphans: int = 0
|
|
33
|
+
dropped_duplicates: int = 0
|
|
34
|
+
reordered: int = 0
|
|
35
|
+
|
|
36
|
+
def counters(self) -> dict[str, int]:
|
|
37
|
+
"""JSON-safe summary for a durable diagnostic record."""
|
|
38
|
+
return {
|
|
39
|
+
"synthesized": self.synthesized,
|
|
40
|
+
"dropped_orphans": self.dropped_orphans,
|
|
41
|
+
"dropped_duplicates": self.dropped_duplicates,
|
|
42
|
+
"reordered": self.reordered,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def mend_tool_history(entries: Sequence[TranscriptEntry]) -> HistoryMend:
|
|
47
|
+
"""Return history in which every tool call has exactly one adjacent result."""
|
|
48
|
+
history = tuple(entries)
|
|
49
|
+
calls = _call_slots(history)
|
|
50
|
+
results: dict[str, list[tuple[int, ToolOutcomeEntry]]] = defaultdict(list)
|
|
51
|
+
for position, entry in enumerate(history):
|
|
52
|
+
if isinstance(entry, ToolOutcomeEntry):
|
|
53
|
+
results[entry.tool_call_id].append((position, entry))
|
|
54
|
+
|
|
55
|
+
chosen: dict[_CallSlot, _Pick] = {}
|
|
56
|
+
taken: set[int] = set()
|
|
57
|
+
|
|
58
|
+
# Pairs that are already adjacent are claimed first, so a legitimately
|
|
59
|
+
# reused id stays with its own turn instead of an earlier call taking it.
|
|
60
|
+
for slot, call, ideal in calls:
|
|
61
|
+
if ideal < len(history) and ideal not in taken:
|
|
62
|
+
candidate = history[ideal]
|
|
63
|
+
if isinstance(candidate, ToolOutcomeEntry) and candidate.tool_call_id == call.id:
|
|
64
|
+
chosen[slot] = (ideal, candidate)
|
|
65
|
+
taken.add(ideal)
|
|
66
|
+
|
|
67
|
+
synthesized = 0
|
|
68
|
+
for slot, call, _ideal in calls:
|
|
69
|
+
if slot in chosen:
|
|
70
|
+
continue
|
|
71
|
+
free = [c for c in results.get(call.id, []) if c[0] not in taken]
|
|
72
|
+
if not free:
|
|
73
|
+
chosen[slot] = (None, _interruption(call))
|
|
74
|
+
synthesized += 1
|
|
75
|
+
continue
|
|
76
|
+
pool = [c for c in free if c[0] > slot[0]] or free
|
|
77
|
+
pick = next((c for c in pool if not _is_interruption(c[1])), pool[0])
|
|
78
|
+
chosen[slot] = pick
|
|
79
|
+
taken.add(pick[0])
|
|
80
|
+
|
|
81
|
+
# A call paired with a synthetic interruption switches to a real result
|
|
82
|
+
# for the same id if one is still unclaimed.
|
|
83
|
+
for slot, call, _ideal in calls:
|
|
84
|
+
claimed, result = chosen[slot]
|
|
85
|
+
if claimed is None or not _is_interruption(result):
|
|
86
|
+
continue
|
|
87
|
+
better = next(
|
|
88
|
+
(c for c in results.get(call.id, [])
|
|
89
|
+
if c[0] not in taken and not _is_interruption(c[1])),
|
|
90
|
+
None,
|
|
91
|
+
)
|
|
92
|
+
if better is not None:
|
|
93
|
+
taken.discard(claimed)
|
|
94
|
+
taken.add(better[0])
|
|
95
|
+
chosen[slot] = better
|
|
96
|
+
|
|
97
|
+
mended: list[TranscriptEntry] = []
|
|
98
|
+
reordered = 0
|
|
99
|
+
for position, entry in enumerate(history):
|
|
100
|
+
if isinstance(entry, ToolOutcomeEntry):
|
|
101
|
+
continue
|
|
102
|
+
mended.append(entry)
|
|
103
|
+
if not isinstance(entry, ModelEntry):
|
|
104
|
+
continue
|
|
105
|
+
for offset, _call in enumerate(entry.tool_calls, start=1):
|
|
106
|
+
source, result = chosen[(position, offset)]
|
|
107
|
+
mended.append(result)
|
|
108
|
+
if source is not None and source != position + offset:
|
|
109
|
+
reordered += 1
|
|
110
|
+
|
|
111
|
+
known_ids = {call.id for _slot, call, _ideal in calls}
|
|
112
|
+
leftovers = [
|
|
113
|
+
result
|
|
114
|
+
for group in results.values()
|
|
115
|
+
for position, result in group
|
|
116
|
+
if position not in taken
|
|
117
|
+
]
|
|
118
|
+
changed = len(mended) != len(history) or any(
|
|
119
|
+
a is not b for a, b in zip(mended, history, strict=False)
|
|
120
|
+
)
|
|
121
|
+
return HistoryMend(
|
|
122
|
+
entries=tuple(mended),
|
|
123
|
+
changed=changed,
|
|
124
|
+
synthesized=synthesized,
|
|
125
|
+
dropped_orphans=sum(r.tool_call_id not in known_ids for r in leftovers),
|
|
126
|
+
dropped_duplicates=sum(r.tool_call_id in known_ids for r in leftovers),
|
|
127
|
+
reordered=reordered,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _call_slots(history: tuple[TranscriptEntry, ...]) -> list[tuple[_CallSlot, CallBlock, int]]:
|
|
132
|
+
slots: list[tuple[_CallSlot, CallBlock, int]] = []
|
|
133
|
+
for position, entry in enumerate(history):
|
|
134
|
+
if isinstance(entry, ModelEntry):
|
|
135
|
+
for offset, call in enumerate(entry.tool_calls, start=1):
|
|
136
|
+
slots.append(((position, offset), call, position + offset))
|
|
137
|
+
return slots
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _interruption(call: CallBlock) -> ToolOutcomeEntry:
|
|
141
|
+
return ToolOutcomeEntry(
|
|
142
|
+
tool_call_id=call.id,
|
|
143
|
+
tool_name=call.name,
|
|
144
|
+
content=[TextSegment(text=INTERRUPTED_RESULT_TEXT)],
|
|
145
|
+
is_error=True,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _is_interruption(result: ToolOutcomeEntry) -> bool:
|
|
150
|
+
return result.is_error and result.text == INTERRUPTED_RESULT_TEXT
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
__all__ = ["INTERRUPTED_RESULT_TEXT", "HistoryMend", "mend_tool_history"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""rlmagent.harness provider seam: wire events + the ModelProvider protocol."""
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Structural protocol for model adapters consumed by the agent loop."""
|
|
2
|
+
#provider
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator, Sequence
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
from rlmagent.harness.contracts.tooling import CancelToken, ToolSpec
|
|
9
|
+
from rlmagent.harness.contracts.transcript import TranscriptEntry
|
|
10
|
+
from rlmagent.harness.provider.wire import WireEvent
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ModelProvider(Protocol):
|
|
14
|
+
"""Structural interface for anything that can produce model completions."""
|
|
15
|
+
|
|
16
|
+
def stream_response(
|
|
17
|
+
self,
|
|
18
|
+
*,
|
|
19
|
+
model: str,
|
|
20
|
+
system: str,
|
|
21
|
+
messages: Sequence[TranscriptEntry],
|
|
22
|
+
tools: Sequence[ToolSpec],
|
|
23
|
+
signal: CancelToken | None = None,
|
|
24
|
+
cache_key: str | None = None,
|
|
25
|
+
) -> AsyncIterator[WireEvent]:
|
|
26
|
+
"""Stream typed wire events for one model round-trip."""
|
|
27
|
+
...
|