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,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
|
rlmagent/agents/patch.py
ADDED
|
@@ -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)
|