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