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,212 @@
1
+ """Every model call in the tree passes through one shared allowance."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import AsyncIterator
7
+ from contextlib import aclosing
8
+ from dataclasses import dataclass
9
+
10
+ from rlmness import Allowance, AllowanceSpent, Spend
11
+
12
+ from rlmagent.harness.contracts.transcript import ModelEntry, TextSegment
13
+ from rlmagent.harness.contracts.transcript.diagnostics import UsageStats
14
+ from rlmagent.harness.provider.wire import StreamCloseEvent, StreamFaultEvent
15
+ from rlmagent.model.token_bounds import TokenBoundError, TokenReservation
16
+
17
+
18
+ def to_spend(usage: UsageStats) -> Spend:
19
+ cost = usage.cost.total
20
+ return Spend(
21
+ prompt_tokens=usage.input,
22
+ completion_tokens=usage.output,
23
+ total_tokens=usage.total_tokens or usage.input + usage.output,
24
+ # A provider that reports no price is not free; say "unknown".
25
+ cost=cost if cost > 0 else None,
26
+ cached_tokens=usage.cache_read or None,
27
+ reasoning_tokens=usage.reasoning,
28
+ )
29
+
30
+
31
+ REFUSED = "budget reached"
32
+
33
+ #: A heuristic for advisory sizing. Token density varies by model and input.
34
+ CHARS_PER_TOKEN = 3
35
+ #: Reply tokens assumed when the provider's own reply limit cannot be read.
36
+ DEFAULT_REPLY_TOKENS = 8192
37
+
38
+
39
+ def _text_of(value) -> str:
40
+ if isinstance(value, str):
41
+ return value
42
+ text = getattr(value, "text", None)
43
+ if isinstance(text, str):
44
+ return text
45
+ content = getattr(value, "content", None)
46
+ if isinstance(content, str):
47
+ return content
48
+ if isinstance(content, list):
49
+ return " ".join(_text_of(part) for part in content)
50
+ return str(value) if value is not None else ""
51
+
52
+
53
+ def estimate_prompt_tokens(system, messages) -> int:
54
+ """Estimate the prompt for advisory sizing, treating it as uncached."""
55
+ chars = len(_text_of(system)) + sum(len(_text_of(m)) for m in messages or ())
56
+ return chars // CHARS_PER_TOKEN + 1
57
+
58
+
59
+ def reply_limit(provider) -> int:
60
+ """The most tokens the provider will write in one reply, as far as it says."""
61
+ seen = set()
62
+ while provider is not None and id(provider) not in seen:
63
+ seen.add(id(provider))
64
+ profile = getattr(provider, "_profile", None)
65
+ limit = getattr(profile, "max_tokens", None)
66
+ if isinstance(limit, int) and limit > 0:
67
+ return limit
68
+ provider = getattr(provider, "inner", None) or getattr(provider, "_inner", None)
69
+ return DEFAULT_REPLY_TOKENS
70
+
71
+
72
+ class NewTokenBudget:
73
+ """Reserve the unmeasured balance for one provider request at a time."""
74
+
75
+ def __init__(self, limit: int, *, strict: bool = False) -> None:
76
+ self.limit = limit
77
+ self.strict = strict
78
+ self.used = 0
79
+ self.unmeasured = False
80
+ self.lock = asyncio.Lock()
81
+
82
+ def refusal(self, upcoming: int = 0) -> str | None:
83
+ """Why the next request may not go out; `upcoming` is its estimated size."""
84
+ if self.unmeasured:
85
+ return "new tokens could not be measured for the preceding request"
86
+ if self.used >= self.limit:
87
+ return f"{self.used} new tokens reaches the limit of {self.limit}"
88
+ if upcoming and self.used + upcoming > self.limit:
89
+ return (f"the next request could use about {upcoming} new tokens, and "
90
+ f"{self.used} of {self.limit} are already used")
91
+ return None
92
+
93
+ def settle(self, usage: UsageStats) -> None:
94
+ tokens = max(usage.input + usage.cache_write + usage.output,
95
+ usage.total_tokens - usage.cache_read)
96
+ if not (tokens or usage.cache_read or usage.total_tokens):
97
+ self.unmeasured = True
98
+ self.used += tokens
99
+
100
+
101
+ @dataclass
102
+ class ContractAllowance(Allowance):
103
+ new_token_budget: NewTokenBudget | None = None
104
+
105
+
106
+ class BudgetedProvider:
107
+ def __init__(self, inner, allowance: Allowance) -> None:
108
+ self.inner = inner
109
+ self.allowance = allowance
110
+ self.refusal: str | None = None
111
+
112
+ def __getattr__(self, name):
113
+ return getattr(self.inner, name)
114
+
115
+ def stream_response(self, *, model, **kwargs) -> AsyncIterator:
116
+ return self.model_stream(self.inner, model=model, reserve=True, **kwargs)
117
+
118
+ def model_stream(self, provider, *, model, reserve=False, **kwargs) -> AsyncIterator:
119
+ budget = getattr(self.allowance, "new_token_budget", None)
120
+
121
+ def request():
122
+ return provider.stream_response(model=model, **kwargs)
123
+
124
+ def start(factory=request):
125
+ return self._reserved_stream(model, factory) if reserve else factory()
126
+
127
+ if budget is None:
128
+ return start()
129
+ return self.measured_stream(provider, model, kwargs, start)
130
+
131
+ def request_size(self, system, messages) -> int:
132
+ """Estimated new tokens of a request: its whole prompt plus the longest reply."""
133
+ return estimate_prompt_tokens(system, messages) + reply_limit(self.inner)
134
+
135
+ def _refuse(self, model, message, usage=None):
136
+ self.refusal = message
137
+ entry = ModelEntry(model=model, content=[TextSegment(text="")], stop_reason="error",
138
+ error_message=f"{REFUSED}: {message}", usage=usage)
139
+ return StreamFaultEvent(reason="error", error=entry)
140
+
141
+ async def _token_plan(self, provider, model, kwargs, budget):
142
+ from rlmagent.model.external import is_external
143
+
144
+ prepare = getattr(provider, "prepare_token_request", None)
145
+ if is_external(model) or prepare is None:
146
+ raise TokenBoundError("this route has no verified input counter and provider-enforced reply cap")
147
+ plan = await prepare(model=model, remaining=budget.limit - budget.used, **kwargs)
148
+ if (not isinstance(plan, TokenReservation) or type(plan.input_tokens) is not int
149
+ or type(plan.output_tokens) is not int or plan.input_tokens < 0
150
+ or plan.output_tokens <= 0 or not callable(plan.start)
151
+ or plan.input_tokens + plan.output_tokens > budget.limit - budget.used):
152
+ raise TokenBoundError("provider did not establish a reservation within the remaining token budget")
153
+ return plan
154
+
155
+ async def measured_stream(self, provider, model, kwargs, start) -> AsyncIterator:
156
+ budget = self.allowance.new_token_budget
157
+ terminal = None
158
+ async with budget.lock:
159
+ plan = None
160
+ upcoming = 0 if budget.strict else self.request_size(kwargs.get("system"), kwargs.get("messages"))
161
+ reason = budget.refusal(upcoming)
162
+ if reason is None and budget.strict:
163
+ try:
164
+ plan = await self._token_plan(provider, model, kwargs, budget)
165
+ except TokenBoundError as exc:
166
+ reason = str(exc)
167
+ if reason is not None:
168
+ terminal = self._refuse(model, reason)
169
+ else:
170
+ settled = False
171
+ try:
172
+ async with aclosing(start(plan.start) if plan else start()) as stream:
173
+ async for event in stream:
174
+ if isinstance(event, StreamCloseEvent):
175
+ budget.settle(event.message.usage)
176
+ settled = True
177
+ terminal = event
178
+ usage = event.message.usage
179
+ if plan and (budget.unmeasured or usage.output > plan.output_tokens
180
+ or max(usage.input + usage.cache_read + usage.cache_write,
181
+ usage.total_tokens - usage.output) > plan.input_tokens):
182
+ budget.unmeasured = True
183
+ terminal = self._refuse(model, "provider usage did not match the strict reservation", usage)
184
+ elif budget.used > budget.limit:
185
+ terminal = self._refuse(model, budget.refusal(), usage)
186
+ break
187
+ if isinstance(event, StreamFaultEvent):
188
+ if not (event.error.error_message or "").startswith(REFUSED):
189
+ budget.settle(event.error.usage)
190
+ if plan:
191
+ budget.unmeasured = True
192
+ settled = True
193
+ terminal = event
194
+ break
195
+ yield event
196
+ finally:
197
+ if not settled:
198
+ budget.unmeasured = True
199
+ if terminal is not None:
200
+ yield terminal
201
+
202
+ async def _reserved_stream(self, model, start) -> AsyncIterator:
203
+ try:
204
+ self.allowance.reserve()
205
+ except AllowanceSpent as exc:
206
+ # No allowance.cancel(): later calls would say "abandoned", not the limit.
207
+ yield self._refuse(model, str(exc))
208
+ return
209
+ async for event in start():
210
+ if isinstance(event, StreamCloseEvent):
211
+ self.allowance.settle(to_spend(event.message.usage))
212
+ yield event
@@ -0,0 +1,46 @@
1
+ """Names and guest helpers for parent-owned child handles."""
2
+
3
+ import re
4
+
5
+
6
+ def checked_name(name: str) -> str:
7
+ if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{0,63}", name):
8
+ raise ValueError("child name must start with a letter and contain up to 64 letters, digits, underscores or hyphens")
9
+ return name
10
+
11
+
12
+ HELPERS = '''
13
+ async def name_child(handle, name):
14
+ """Assign or change a name under the current parent and return the same handle."""
15
+ await _child_operation("name", handle.id, name)
16
+ return handle
17
+
18
+ async def find_child(name):
19
+ """Look up one of your children by name."""
20
+ return _child_handle((await _child_operation("find", None, name))["id"])
21
+
22
+ async def child_info(handle):
23
+ """Inspect a child's status, identity and recorded usage without consuming its output."""
24
+ return await _child_operation("info", handle.id)
25
+
26
+ async def child_messages(handle, new_only=True):
27
+ """List metadata for child notes without marking them read."""
28
+ return await _child_operation("messages", handle.id, new_only=new_only)
29
+
30
+ async def read_child_message(handle, message_id):
31
+ """Read a specific note and keep its receipt available for later lookup."""
32
+ return await _child_operation("read_message", handle.id, identity=message_id)
33
+
34
+ async def steer_child(handle, text, message_id=None):
35
+ """Deliver an instruction at an active child's next model request."""
36
+ return await _child_operation("steer", handle.id, text=text, message_id=message_id)
37
+ '''
38
+
39
+
40
+ def compatibility_helpers(call: str) -> str:
41
+ return f'''
42
+ _child_handle = _SubAgent
43
+
44
+ async def _child_operation(action, key=None, name=None, **arguments):
45
+ return await {call}("child_handle", {{"action": action, "id": key, "name": name, **arguments}})
46
+ ''' + HELPERS
@@ -0,0 +1,30 @@
1
+ """Read-state bookkeeping for parent-owned child notes."""
2
+
3
+ from time import time
4
+ from uuid import uuid4
5
+
6
+
7
+ def record_notes(notes, previous, current):
8
+ """Fold a mailbox snapshot. Each runtime serializes its appends and full drains."""
9
+ if previous == current:
10
+ return notes
11
+ if not current:
12
+ pending = len(previous)
13
+ return notes[:-pending] + [{**note, "status": "read"} for note in notes[-pending:]]
14
+ return notes + [{"id": uuid4().hex, "text": text, "status": "unread", "time": time()}
15
+ for text in current[len(previous):]]
16
+
17
+
18
+ def notes_metadata(notes, new_only):
19
+ return [{k: v for k, v in note.items() if k != "text"}
20
+ for note in notes if not new_only or note["status"] == "unread"]
21
+
22
+
23
+ def read_note(notes, identity):
24
+ for index, note in enumerate(notes):
25
+ if note["id"] == identity:
26
+ if note["status"] == "read":
27
+ return notes, dict(note)
28
+ read = {**note, "status": "read"}
29
+ return notes[:index] + [read] + notes[index + 1:], dict(read)
30
+ return notes, None
@@ -0,0 +1,156 @@
1
+ """Code run in every kernel before the model's first cell."""
2
+
3
+ from textwrap import indent
4
+
5
+ from rlmagent.agents.child_handles import compatibility_helpers
6
+
7
+ KERNEL_API = r'''
8
+ import asyncio as _rlmagent_asyncio
9
+ import json as _rlmagent_json
10
+ import os as _rlmagent_os
11
+ import socket as _rlmagent_socket
12
+
13
+ _RLMAGENT = {
14
+ "port": int(_rlmagent_os.environ["RLM_AGENT_BRIDGE_PORT"]),
15
+ "token": _rlmagent_os.environ["RLM_AGENT_BRIDGE_TOKEN"],
16
+ }
17
+
18
+
19
+ def _rlmagent_payload(op, args):
20
+ return (_rlmagent_json.dumps({"token": _RLMAGENT["token"], "op": op, "args": args})
21
+ + "\n").encode()
22
+
23
+
24
+ def _rlmagent_reply(line):
25
+ reply = _rlmagent_json.loads(line)
26
+ if "error" in reply:
27
+ kind = TimeoutError if reply.get("type") == "TimeoutError" else RuntimeError
28
+ raise kind(reply["error"])
29
+ return reply["ok"]
30
+
31
+
32
+ def _rlmagent_call_sync(op, args):
33
+ with _rlmagent_socket.create_connection(("127.0.0.1", _RLMAGENT["port"])) as sock:
34
+ sock.sendall(_rlmagent_payload(op, args))
35
+ chunks = []
36
+ while True:
37
+ chunk = sock.recv(1 << 16)
38
+ if not chunk:
39
+ break
40
+ chunks.append(chunk)
41
+ if chunk.endswith(b"\n"):
42
+ break
43
+ return _rlmagent_reply(b"".join(chunks))
44
+
45
+
46
+ async def _rlmagent_call(op, args):
47
+ reader, writer = await _rlmagent_asyncio.open_connection(
48
+ "127.0.0.1", _RLMAGENT["port"], limit=2**26)
49
+ writer.write(_rlmagent_payload(op, args))
50
+ await writer.drain()
51
+ line = await reader.readline()
52
+ writer.close()
53
+ return _rlmagent_reply(line)
54
+
55
+
56
+ if _rlmagent_os.environ.get("RLM_AGENT_CAN_DELEGATE") == "1":
57
+ async def rlm(task, context=None, role=None, model=None):
58
+ """Hand `task` to a sub-agent; `context` arrives in its kernel as CONTEXT. Returns its FINAL value."""
59
+ return await _rlmagent_call("rlm", {"task": str(task), "context": context, "role": role,
60
+ "model": model})
61
+
62
+ async def gather_rlm(jobs):
63
+ """Run several sub-agents at once. `jobs` is a list of (task, context) pairs or tasks. Results keep order."""
64
+ pairs = [list(j) if isinstance(j, (tuple, list)) else [j, None] for j in jobs]
65
+ return await _rlmagent_call("gather", {"jobs": [[str(t), c] for t, c in pairs]})
66
+
67
+ class _SubAgent:
68
+ """A sub-agent started with spawn(). Losing this object does not stop it."""
69
+
70
+ def __init__(self, id):
71
+ self.id = id
72
+
73
+ def __repr__(self):
74
+ return f"<sub-agent {self.id}>"
75
+
76
+ async def status(self):
77
+ """'starting', 'running', 'idle', 'done', 'failed' or 'cancelled'."""
78
+ return await _rlmagent_call("status", {"id": self.id})
79
+
80
+ async def result(self, timeout=None):
81
+ """Its FINAL value; waits for it. TimeoutError if still running after `timeout` seconds."""
82
+ return await _rlmagent_call("result", {"id": self.id, "timeout": timeout})
83
+
84
+ async def cancel(self):
85
+ """Stop it and every sub-agent it started."""
86
+ return await _rlmagent_call("cancel", {"id": self.id})
87
+
88
+ async def send(self, text):
89
+ """Give it another instruction; wakes it if it is idle."""
90
+ return await _rlmagent_call("send", {"id": self.id, "text": str(text)})
91
+
92
+ async def messages(self):
93
+ """Notes it sent with tell_parent, oldest first; each is returned once."""
94
+ return await _rlmagent_call("messages", {"id": self.id})
95
+
96
+
97
+ if _rlmagent_os.environ.get("RLM_AGENT_CAN_DELEGATE") == "1":
98
+ async def spawn(task, data=None, keep=False, role=None, model=None):
99
+ """Start a sub-agent and return at once. keep=True: it stays idle after FINAL, ready for send()."""
100
+ return _SubAgent(await _rlmagent_call(
101
+ "spawn", {"task": str(task), "context": data, "keep": bool(keep), "role": role,
102
+ "model": model}))
103
+
104
+ async def children():
105
+ """Every sub-agent you started, in order."""
106
+ return [_SubAgent(i) for i in await _rlmagent_call("children", {})]
107
+
108
+ async def improve(instructions=None, shared=False):
109
+ """Ask for the playbook to be updated from this conversation when the turn ends."""
110
+ return await _rlmagent_call("improve", {"instructions": instructions, "shared": bool(shared)})
111
+
112
+
113
+ async def capabilities():
114
+ """The limits and approval setting in force, read from the harness."""
115
+ return await _rlmagent_call("capabilities", {})
116
+
117
+
118
+ async def guidance(id=None):
119
+ return await _rlmagent_call("guidance", {"id": id})
120
+
121
+
122
+ async def playbook(id=None):
123
+ """One playbook entry in full, or a list of all of them."""
124
+ return await _rlmagent_call("playbook", {"id": id})
125
+
126
+ async def skill(id):
127
+ """Load a playbook skill and return its function."""
128
+ entry = await playbook(id)
129
+ if entry["kind"] != "skill":
130
+ raise ValueError(f"{id} is a {entry['kind']}, not a skill")
131
+ namespace = {}
132
+ exec(compile(entry["content"], f"<skill {id}>", "exec"), namespace)
133
+ return namespace[entry["callable"]]
134
+
135
+
136
+ if _rlmagent_os.environ.get("RLM_AGENT_IS_CHILD") == "1":
137
+ async def tell_parent(text):
138
+ """Leave a note the agent that started you can read with messages()."""
139
+ await _rlmagent_call("tell_parent", {"text": str(text)})
140
+
141
+ def FINAL(value):
142
+ """Return `value` to the agent that started you. Call once, when done."""
143
+ try:
144
+ _rlmagent_json.dumps(value)
145
+ args = {"value": value, "note": ""}
146
+ except (TypeError, ValueError):
147
+ args = {"value": repr(value), "note": "value was not JSON-serialisable; sent as repr"}
148
+ _rlmagent_call_sync("final", args)
149
+ print("FINAL recorded.")
150
+
151
+ CONTEXT = _rlmagent_call_sync("context", {})
152
+ '''
153
+
154
+ KERNEL_API += '\nif _rlmagent_os.environ.get("RLM_AGENT_CAN_DELEGATE") == "1":\n' + indent(
155
+ compatibility_helpers("_rlmagent_call"), " ",
156
+ )
@@ -0,0 +1,34 @@
1
+ """Bound retained command output while draining the entire pipe."""
2
+
3
+ BYTE_LIMIT = 50 * 1024
4
+ LINE_LIMIT = 2000
5
+
6
+
7
+ class OutputTail:
8
+ def __init__(self):
9
+ self.data = bytearray()
10
+ self.total = 0
11
+ self.truncated = False
12
+
13
+ def feed(self, chunk):
14
+ self.total += len(chunk)
15
+ self.data.extend(chunk)
16
+ # Leave room for the truncation notice in the returned output.
17
+ capacity = BYTE_LIMIT - 128
18
+ if len(self.data) > capacity:
19
+ del self.data[:-capacity]
20
+ self.truncated = True
21
+ lines = self.data.count(b"\n") + bool(self.data and self.data[-1] != 10)
22
+ limit = LINE_LIMIT - 1 if self.truncated else LINE_LIMIT
23
+ while lines > limit:
24
+ del self.data[: self.data.index(10) + 1]
25
+ lines -= 1
26
+ self.truncated = True
27
+ limit = LINE_LIMIT - 1
28
+
29
+ def text(self):
30
+ # Ignore an incomplete leading UTF-8 character cut by the byte boundary.
31
+ text = bytes(self.data).decode("utf-8", errors="ignore")
32
+ if self.truncated:
33
+ return f"[output truncated; {self.total} bytes produced]\n" + text
34
+ return text
@@ -0,0 +1,107 @@
1
+ """Apply a unified diff to several files: everything is checked before anything is written."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+
8
+ _HUNK = re.compile(r"@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@")
9
+
10
+
11
+ class PatchError(ValueError):
12
+ pass
13
+
14
+
15
+ @dataclass
16
+ class Hunk:
17
+ start: int # 1-based line in the old file; 0 when it has no old lines
18
+ old: list[str] = field(default_factory=list)
19
+ new: list[str] = field(default_factory=list)
20
+
21
+
22
+ @dataclass
23
+ class FilePatch:
24
+ old_path: str | None # None: the file is created
25
+ new_path: str | None # None: the file is deleted
26
+ hunks: list[Hunk] = field(default_factory=list)
27
+
28
+ @property
29
+ def path(self) -> str:
30
+ return self.new_path or self.old_path
31
+
32
+
33
+ def _name(raw: str) -> str | None:
34
+ name = raw.split("\t")[0].strip()
35
+ if name == "/dev/null":
36
+ return None
37
+ return name[2:] if name.startswith(("a/", "b/")) else name
38
+
39
+
40
+ def parse(diff: str) -> list[FilePatch]:
41
+ files: list[FilePatch] = []
42
+ lines = diff.splitlines()
43
+ i = 0
44
+ while i < len(lines):
45
+ if not lines[i].startswith("--- "):
46
+ i += 1
47
+ continue
48
+ if i + 1 >= len(lines) or not lines[i + 1].startswith("+++ "):
49
+ raise PatchError(f"line {i + 1}: '---' without a following '+++'")
50
+ current = FilePatch(_name(lines[i][4:]), _name(lines[i + 1][4:]))
51
+ if current.old_path is None and current.new_path is None:
52
+ raise PatchError(f"line {i + 1}: both sides are /dev/null")
53
+ files.append(current)
54
+ i += 2
55
+ while i < len(lines) and lines[i].startswith("@@"):
56
+ found = _HUNK.match(lines[i])
57
+ if found is None:
58
+ raise PatchError(f"line {i + 1}: bad hunk header")
59
+ hunk = Hunk(int(found.group(1)))
60
+ current.hunks.append(hunk)
61
+ i += 1
62
+ while i < len(lines) and not lines[i].startswith(("@@", "--- ")):
63
+ line = lines[i]
64
+ if line.startswith("\\"):
65
+ pass # "No newline at end of file": the file keeps its own ending
66
+ elif line.startswith("-"):
67
+ hunk.old.append(line[1:])
68
+ elif line.startswith("+"):
69
+ hunk.new.append(line[1:])
70
+ elif line.startswith(" ") or not line:
71
+ hunk.old.append(line[1:])
72
+ hunk.new.append(line[1:])
73
+ else:
74
+ break # the text after the last hunk, such as a "diff --git" line
75
+ i += 1
76
+ if not files:
77
+ raise PatchError("no file changes found; expected '---' and '+++' lines")
78
+ return files
79
+
80
+
81
+ def _locate(lines: list[str], hunk: Hunk, floor: int, path: str) -> int:
82
+ size = len(hunk.old)
83
+ stated = max(hunk.start - 1, 0)
84
+ if stated >= floor and lines[stated:stated + size] == hunk.old:
85
+ return stated
86
+ places = [at for at in range(floor, len(lines) - size + 1) if lines[at:at + size] == hunk.old]
87
+ if len(places) == 1:
88
+ return places[0]
89
+ what = "not found" if not places else f"found {len(places)} times"
90
+ raise PatchError(f"{path}: the hunk at line {hunk.start} was {what}; read the file again")
91
+
92
+
93
+ def patched(text: str, patch: FilePatch) -> str:
94
+ """The file's new text; raises PatchError when a hunk does not fit."""
95
+ newline = "\r\n" if "\r\n" in text else "\n"
96
+ ends_open = bool(text) and not text.endswith("\n")
97
+ lines = text.splitlines()
98
+ result: list[str] = []
99
+ position = 0
100
+ for hunk in patch.hunks:
101
+ at = _locate(lines, hunk, position, patch.path)
102
+ result += lines[position:at] + hunk.new
103
+ position = at + len(hunk.old)
104
+ result += lines[position:]
105
+ if not result:
106
+ return ""
107
+ return newline.join(result) + ("" if ends_open else newline)