rlmness-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rlmagent/__init__.py +1 -0
- rlmagent/__main__.py +8 -0
- rlmagent/acp/__init__.py +1 -0
- rlmagent/acp/runtime.py +71 -0
- rlmagent/acp/server.py +598 -0
- rlmagent/acp/updates.py +128 -0
- rlmagent/agentnet.py +237 -0
- rlmagent/agents/__init__.py +1 -0
- rlmagent/agents/approval.py +90 -0
- rlmagent/agents/bridge.py +109 -0
- rlmagent/agents/budget.py +212 -0
- rlmagent/agents/child_handles.py +46 -0
- rlmagent/agents/child_mailbox.py +30 -0
- rlmagent/agents/kernel_api.py +156 -0
- rlmagent/agents/output.py +34 -0
- rlmagent/agents/patch.py +107 -0
- rlmagent/agents/prompts.py +156 -0
- rlmagent/agents/trace.py +290 -0
- rlmagent/agents/tree.py +682 -0
- rlmagent/agents/wasm_api.py +105 -0
- rlmagent/agents/workspace.py +357 -0
- rlmagent/autopilot.py +447 -0
- rlmagent/backend.py +946 -0
- rlmagent/backend_context.py +74 -0
- rlmagent/cli/__init__.py +5 -0
- rlmagent/cli/config_cmd.py +177 -0
- rlmagent/cli/health.py +156 -0
- rlmagent/cli/main.py +1194 -0
- rlmagent/cli/update.py +188 -0
- rlmagent/clipboard.py +63 -0
- rlmagent/config/__init__.py +37 -0
- rlmagent/config/config.py +297 -0
- rlmagent/config/loader.py +119 -0
- rlmagent/config/prompts.py +124 -0
- rlmagent/config/secure_input.py +213 -0
- rlmagent/config/store.py +275 -0
- rlmagent/context/__init__.py +47 -0
- rlmagent/context/budget.py +557 -0
- rlmagent/contract.py +311 -0
- rlmagent/contract_run.py +232 -0
- rlmagent/conversation.py +2547 -0
- rlmagent/cron.py +77 -0
- rlmagent/directives.py +893 -0
- rlmagent/discovery.py +356 -0
- rlmagent/execution_log.py +66 -0
- rlmagent/factory.py +189 -0
- rlmagent/factory_graph.py +413 -0
- rlmagent/harness/__init__.py +63 -0
- rlmagent/harness/contracts/__init__.py +1 -0
- rlmagent/harness/contracts/stream.py +129 -0
- rlmagent/harness/contracts/tooling.py +127 -0
- rlmagent/harness/contracts/transcript/__init__.py +68 -0
- rlmagent/harness/contracts/transcript/_base.py +29 -0
- rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
- rlmagent/harness/contracts/transcript/entries.py +180 -0
- rlmagent/harness/contracts/transcript/segments.py +64 -0
- rlmagent/harness/contracts/values.py +6 -0
- rlmagent/harness/driver.py +397 -0
- rlmagent/harness/engine.py +489 -0
- rlmagent/harness/mending.py +153 -0
- rlmagent/harness/provider/__init__.py +1 -0
- rlmagent/harness/provider/base.py +27 -0
- rlmagent/harness/provider/wire.py +189 -0
- rlmagent/harness/py.typed +0 -0
- rlmagent/harness/session/__init__.py +5 -0
- rlmagent/harness/session/index.py +218 -0
- rlmagent/harness/session/lease.py +66 -0
- rlmagent/harness/session/records.py +118 -0
- rlmagent/harness/session/replay.py +165 -0
- rlmagent/harness/session/store.py +155 -0
- rlmagent/harness/session/summary.py +46 -0
- rlmagent/hints.py +47 -0
- rlmagent/history.py +92 -0
- rlmagent/hooks/__init__.py +1 -0
- rlmagent/hooks/hooks.py +1 -0
- rlmagent/instructions.py +157 -0
- rlmagent/job_runner.py +117 -0
- rlmagent/jobs.py +365 -0
- rlmagent/kept_agents.py +397 -0
- rlmagent/kernel/__init__.py +5 -0
- rlmagent/kernel/session.py +205 -0
- rlmagent/kernel/wasm.py +169 -0
- rlmagent/logs.py +208 -0
- rlmagent/managed_release.py +368 -0
- rlmagent/mcp.py +411 -0
- rlmagent/mcp_oauth.py +213 -0
- rlmagent/model/__init__.py +29 -0
- rlmagent/model/_claude/__init__.py +1 -0
- rlmagent/model/_claude/courier.py +117 -0
- rlmagent/model/_claude/emitter.py +378 -0
- rlmagent/model/_oai/__init__.py +1 -0
- rlmagent/model/_oai/helpers.py +256 -0
- rlmagent/model/_oai/normalize.py +263 -0
- rlmagent/model/_oai/parsers.py +317 -0
- rlmagent/model/_oai/payloads.py +226 -0
- rlmagent/model/_oai/transport.py +127 -0
- rlmagent/model/claude.py +552 -0
- rlmagent/model/copilot.py +142 -0
- rlmagent/model/correlation.py +21 -0
- rlmagent/model/deadline.py +19 -0
- rlmagent/model/discovery.py +181 -0
- rlmagent/model/external/__init__.py +11 -0
- rlmagent/model/external/acp.py +202 -0
- rlmagent/model/external/acp_install.py +109 -0
- rlmagent/model/external/cli.py +248 -0
- rlmagent/model/external/textcalls.py +57 -0
- rlmagent/model/failover.py +129 -0
- rlmagent/model/google.py +199 -0
- rlmagent/model/limits.py +46 -0
- rlmagent/model/oai_compatible.py +414 -0
- rlmagent/model/preferences.py +56 -0
- rlmagent/model/py.typed +0 -0
- rlmagent/model/registered_oauth.py +195 -0
- rlmagent/model/scripted.py +55 -0
- rlmagent/model/settings.py +271 -0
- rlmagent/model/subscription.py +398 -0
- rlmagent/model/token_bounds.py +15 -0
- rlmagent/model/transport/__init__.py +1 -0
- rlmagent/model/transport/backoff.py +87 -0
- rlmagent/model/transport/client.py +87 -0
- rlmagent/model/transport/faults.py +65 -0
- rlmagent/model/transport/responses_socket.py +223 -0
- rlmagent/model/vertex.py +93 -0
- rlmagent/network.py +123 -0
- rlmagent/packages.py +246 -0
- rlmagent/planning.py +84 -0
- rlmagent/playbook/__init__.py +1 -0
- rlmagent/playbook/apply.py +106 -0
- rlmagent/playbook/digest.py +63 -0
- rlmagent/playbook/improver.py +272 -0
- rlmagent/playbook/plan.py +99 -0
- rlmagent/playbook/store.py +67 -0
- rlmagent/plugins.py +327 -0
- rlmagent/policy.py +105 -0
- rlmagent/prompt_refs.py +57 -0
- rlmagent/prompts.py +331 -0
- rlmagent/prune.py +60 -0
- rlmagent/py.typed +0 -0
- rlmagent/reasoning.py +135 -0
- rlmagent/refinement/__init__.py +1 -0
- rlmagent/refinement/controller.py +448 -0
- rlmagent/refinement/engine.py +220 -0
- rlmagent/refinement/evidence.py +131 -0
- rlmagent/refinement/scoped.py +90 -0
- rlmagent/refinement/store.py +193 -0
- rlmagent/refresh.py +202 -0
- rlmagent/routing.py +52 -0
- rlmagent/runtime.py +575 -0
- rlmagent/safety.py +267 -0
- rlmagent/session_html.py +57 -0
- rlmagent/session_import.py +52 -0
- rlmagent/session_share.py +55 -0
- rlmagent/sessions.py +249 -0
- rlmagent/shell_sandbox.py +88 -0
- rlmagent/skillset.py +320 -0
- rlmagent/steering.py +148 -0
- rlmagent/stops.py +62 -0
- rlmagent/subscriptions.py +219 -0
- rlmagent/tasks.py +98 -0
- rlmagent/toolhost.py +310 -0
- rlmagent/tools/__init__.py +56 -0
- rlmagent/tools/_shared.py +210 -0
- rlmagent/tools/files.py +462 -0
- rlmagent/tools/kernel.py +93 -0
- rlmagent/tools/patch_tool.py +68 -0
- rlmagent/tools/search.py +188 -0
- rlmagent/trust.py +383 -0
- rlmagent/tui/__init__.py +1 -0
- rlmagent/tui/adapter.py +506 -0
- rlmagent/tui/app.py +711 -0
- rlmagent/tui/approval_screen.py +57 -0
- rlmagent/tui/choice_screen.py +47 -0
- rlmagent/tui/completions.py +116 -0
- rlmagent/tui/components.py +25 -0
- rlmagent/tui/composer.py +40 -0
- rlmagent/tui/factory_view.py +86 -0
- rlmagent/tui/inspect.py +193 -0
- rlmagent/tui/messages.py +215 -0
- rlmagent/tui/model_screen.py +307 -0
- rlmagent/tui/prompt.py +98 -0
- rlmagent/tui/question_screen.py +52 -0
- rlmagent/tui/replay.py +141 -0
- rlmagent/tui/rlm_agent.tcss +405 -0
- rlmagent/tui/search_box.py +91 -0
- rlmagent/tui/sidebar.py +182 -0
- rlmagent/tui/status.py +49 -0
- rlmagent/tui/steps.py +283 -0
- rlmagent/tui/suggest.py +778 -0
- rlmagent/tui/transcript.py +172 -0
- rlmagent/tui/trust_dialog.py +58 -0
- rlmagent/tui/worker_attach.py +141 -0
- rlmagent/ui/__init__.py +1 -0
- rlmagent/ui/render.py +211 -0
- rlmagent/undo.py +109 -0
- rlmagent/utils/__init__.py +1 -0
- rlmagent/websearch.py +75 -0
- rlmagent/worker.py +1275 -0
- rlmagent/worker_service.py +203 -0
- rlmness_cli-0.2.0.dist-info/METADATA +882 -0
- rlmness_cli-0.2.0.dist-info/RECORD +203 -0
- rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
- rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
- rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
rlmagent/acp/updates.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Our events turned into ACP session/update payloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rlmagent.harness.contracts.stream import (
|
|
6
|
+
MessageEndEvent,
|
|
7
|
+
MessageUpdateEvent,
|
|
8
|
+
RetryEvent,
|
|
9
|
+
ToolRunEndEvent,
|
|
10
|
+
ToolRunStartEvent,
|
|
11
|
+
ToolRunUpdateEvent,
|
|
12
|
+
)
|
|
13
|
+
from rlmagent.harness.contracts.transcript.entries import ModelEntry
|
|
14
|
+
from rlmagent.harness.contracts.transcript.segments import TextSegment, ThoughtSegment
|
|
15
|
+
|
|
16
|
+
KINDS = {"python": "execute", "Read": "read", "Write": "edit", "Edit": "edit"}
|
|
17
|
+
PLAN_STATUS = {"starting": "pending", "running": "in_progress", "idle": "in_progress"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def chunk(kind: str, text: str) -> dict:
|
|
21
|
+
return {"sessionUpdate": kind, "content": {"type": "text", "text": text}}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _title(name: str, args: dict) -> str:
|
|
25
|
+
if name == "python":
|
|
26
|
+
first = str(args.get("code", "")).strip().splitlines()
|
|
27
|
+
return first[0][:80] if first else "python"
|
|
28
|
+
path = args.get("path") or args.get("file_path")
|
|
29
|
+
return f"{name} {path}" if path else name
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _text_content(text: str) -> list[dict]:
|
|
33
|
+
return [{"type": "content", "content": {"type": "text", "text": text}}] if text else []
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Translator:
|
|
37
|
+
"""Keeps how much of the current reply was already sent, so updates carry only deltas."""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self.sent_text = 0
|
|
41
|
+
self.sent_thought = 0
|
|
42
|
+
|
|
43
|
+
def _deltas(self, msg: ModelEntry) -> list[dict]:
|
|
44
|
+
text = "".join(b.text for b in msg.content if isinstance(b, TextSegment))
|
|
45
|
+
thought = "".join(b.thinking for b in msg.content if isinstance(b, ThoughtSegment))
|
|
46
|
+
out = []
|
|
47
|
+
if len(thought) > self.sent_thought:
|
|
48
|
+
out.append(chunk("agent_thought_chunk", thought[self.sent_thought:]))
|
|
49
|
+
self.sent_thought = len(thought)
|
|
50
|
+
if len(text) > self.sent_text:
|
|
51
|
+
out.append(chunk("agent_message_chunk", text[self.sent_text:]))
|
|
52
|
+
self.sent_text = len(text)
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
def updates(self, event) -> list[dict]:
|
|
56
|
+
if isinstance(event, MessageUpdateEvent) and isinstance(event.message, ModelEntry):
|
|
57
|
+
return self._deltas(event.message)
|
|
58
|
+
if isinstance(event, MessageEndEvent) and isinstance(event.message, ModelEntry):
|
|
59
|
+
out = self._deltas(event.message)
|
|
60
|
+
if event.message.stop_reason == "error" and event.message.error_message:
|
|
61
|
+
out.append(chunk("agent_message_chunk", f"\n\nError: {event.message.error_message}"))
|
|
62
|
+
self.sent_text = self.sent_thought = 0
|
|
63
|
+
return out
|
|
64
|
+
if isinstance(event, ToolRunStartEvent):
|
|
65
|
+
update = {"sessionUpdate": "tool_call", "toolCallId": event.tool_call_id,
|
|
66
|
+
"title": _title(event.tool_name, event.args),
|
|
67
|
+
"kind": KINDS.get(event.tool_name, "other"), "status": "in_progress",
|
|
68
|
+
"rawInput": event.args}
|
|
69
|
+
path = event.args.get("path") or event.args.get("file_path")
|
|
70
|
+
if path:
|
|
71
|
+
update["locations"] = [{"path": str(path)}]
|
|
72
|
+
return [update]
|
|
73
|
+
if isinstance(event, ToolRunUpdateEvent):
|
|
74
|
+
return [{"sessionUpdate": "tool_call_update", "toolCallId": event.tool_call_id,
|
|
75
|
+
"content": _text_content(event.partial_result.text)}]
|
|
76
|
+
if isinstance(event, ToolRunEndEvent):
|
|
77
|
+
return [{"sessionUpdate": "tool_call_update", "toolCallId": event.tool_call_id,
|
|
78
|
+
"status": "failed" if event.is_error else "completed",
|
|
79
|
+
"content": _text_content(event.result.text)}]
|
|
80
|
+
if isinstance(event, RetryEvent):
|
|
81
|
+
return [chunk("agent_thought_chunk", f"[retry] {event.message}\n")]
|
|
82
|
+
return []
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def stop_reason(messages: list) -> str:
|
|
86
|
+
last = next((m for m in reversed(messages) if isinstance(m, ModelEntry)), None)
|
|
87
|
+
if last is None:
|
|
88
|
+
return "end_turn"
|
|
89
|
+
if last.stop_reason == "aborted":
|
|
90
|
+
return "cancelled"
|
|
91
|
+
if last.stop_reason == "length":
|
|
92
|
+
return "max_tokens"
|
|
93
|
+
if last.stop_reason == "error" and any(
|
|
94
|
+
w in (last.error_message or "").lower() for w in ("budget", "limit", "allowance")):
|
|
95
|
+
return "max_turn_requests"
|
|
96
|
+
return "end_turn"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def plan(tree) -> list[dict]:
|
|
100
|
+
"""Every sub-agent as one plan entry, nested ones indented by depth."""
|
|
101
|
+
entries = []
|
|
102
|
+
for agent in list(getattr(tree, "backend_agents", {}).values()):
|
|
103
|
+
if agent.get("depth", 0) == 0:
|
|
104
|
+
continue
|
|
105
|
+
entries.append({"content": " " * max(agent.get("depth", 1) - 1, 0)
|
|
106
|
+
+ str(agent.get("instruction") or agent["run_id"])[:120],
|
|
107
|
+
"priority": "medium", "status": "in_progress"
|
|
108
|
+
if agent["status"] == "running" else "completed"})
|
|
109
|
+
for rec in tree.records.values():
|
|
110
|
+
node = rec.node
|
|
111
|
+
status = PLAN_STATUS.get(rec.status, "completed")
|
|
112
|
+
suffix = f" ({rec.status})" if rec.status in ("failed", "cancelled") else ""
|
|
113
|
+
task = (node.task.strip().splitlines() or [node.id])[0][:120]
|
|
114
|
+
entries.append({"content": " " * max(node.depth - 1, 0) + task + suffix,
|
|
115
|
+
"priority": "medium", "status": status})
|
|
116
|
+
return entries
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def replay(transcript) -> list[dict]:
|
|
120
|
+
"""A loaded session's history as message updates."""
|
|
121
|
+
out = []
|
|
122
|
+
for msg in transcript:
|
|
123
|
+
role = getattr(msg, "role", "")
|
|
124
|
+
if role == "user":
|
|
125
|
+
out.append(chunk("user_message_chunk", msg.text))
|
|
126
|
+
elif role == "assistant" and msg.text:
|
|
127
|
+
out.append(chunk("agent_message_chunk", msg.text))
|
|
128
|
+
return out
|
rlmagent/agentnet.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Agents on this machine finding and messaging each other, scheduling work, reporting progress.
|
|
2
|
+
|
|
3
|
+
Every interactive session registers itself under <home>/agents/<id>/ while it runs; workers
|
|
4
|
+
are already listed under <home>/workers/. A message to a worker becomes its next task; a
|
|
5
|
+
message to a session waits in its inbox and is shown to it before its next turn.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
MAX_MESSAGE = 8_000
|
|
18
|
+
MAX_MESSAGES = 50
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _home() -> Path:
|
|
22
|
+
from rlmagent.plugins import rlm_agent_home
|
|
23
|
+
|
|
24
|
+
return rlm_agent_home() / "agents"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _write(path: Path, value: dict) -> None:
|
|
28
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
staged = path.with_name(f".{path.name}.{uuid.uuid4().hex[:8]}")
|
|
30
|
+
staged.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
|
|
31
|
+
os.replace(staged, path)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _read(path: Path) -> dict | None:
|
|
35
|
+
try:
|
|
36
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
37
|
+
except (OSError, ValueError):
|
|
38
|
+
return None
|
|
39
|
+
return value if isinstance(value, dict) else None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AgentNet:
|
|
43
|
+
"""This agent's place among the others: its name, inbox, and what it can reach."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, me: str, cwd: str, worker: str | None = None) -> None:
|
|
46
|
+
self.me = me
|
|
47
|
+
self.cwd = cwd
|
|
48
|
+
self.worker = worker # set when this session is a background worker
|
|
49
|
+
self.on_progress = None # called with progress text, to show it to the user
|
|
50
|
+
|
|
51
|
+
# -- registration ------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
def _folder(self, name: str) -> Path:
|
|
54
|
+
return _home() / name
|
|
55
|
+
|
|
56
|
+
def register(self) -> None:
|
|
57
|
+
if self.worker is None:
|
|
58
|
+
_write(self._folder(self.me) / "meta.json", {
|
|
59
|
+
"pid": os.getpid(), "cwd": self.cwd, "started": time.time(),
|
|
60
|
+
"beat": time.time()})
|
|
61
|
+
|
|
62
|
+
def beat(self) -> None:
|
|
63
|
+
meta = _read(self._folder(self.me) / "meta.json")
|
|
64
|
+
if meta is not None:
|
|
65
|
+
meta["beat"] = time.time()
|
|
66
|
+
_write(self._folder(self.me) / "meta.json", meta)
|
|
67
|
+
|
|
68
|
+
def leave(self) -> None:
|
|
69
|
+
(self._folder(self.me) / "meta.json").unlink(missing_ok=True)
|
|
70
|
+
|
|
71
|
+
# -- functions agents call ---------------------------------------------
|
|
72
|
+
|
|
73
|
+
async def list_agents(self) -> list[dict]:
|
|
74
|
+
"""Other agents on this machine you can message: background workers and open sessions."""
|
|
75
|
+
from rlmagent import worker
|
|
76
|
+
from rlmagent.worker import _pid_alive
|
|
77
|
+
|
|
78
|
+
found = []
|
|
79
|
+
base = worker.root()
|
|
80
|
+
for path in sorted(base.iterdir()) if base.is_dir() else []:
|
|
81
|
+
if path.is_dir():
|
|
82
|
+
meta = worker.state_of(path.name)
|
|
83
|
+
found.append({"name": path.name, "kind": "worker",
|
|
84
|
+
"state": meta.get("state", "?") if meta["alive"] else "not running",
|
|
85
|
+
"cwd": meta.get("cwd"), "me": path.name == self.worker})
|
|
86
|
+
sessions = _home()
|
|
87
|
+
for path in sorted(sessions.iterdir()) if sessions.is_dir() else []:
|
|
88
|
+
meta = _read(path / "meta.json")
|
|
89
|
+
if meta is None:
|
|
90
|
+
continue
|
|
91
|
+
alive = bool(meta.get("pid")) and _pid_alive(int(meta["pid"]))
|
|
92
|
+
if alive or path.name == self.me:
|
|
93
|
+
found.append({"name": path.name, "kind": "session", "state": "open",
|
|
94
|
+
"cwd": meta.get("cwd"), "me": path.name == self.me})
|
|
95
|
+
return found
|
|
96
|
+
|
|
97
|
+
async def send_message(self, to: str, text: str, *, receipt: bool = False) -> str | dict:
|
|
98
|
+
"""Send text to an agent. Set receipt=True to get an ID for message_receipt()."""
|
|
99
|
+
from rlmagent import worker
|
|
100
|
+
|
|
101
|
+
text = str(text)[:MAX_MESSAGE]
|
|
102
|
+
sender = self.worker or self.me
|
|
103
|
+
if to in (self.me, self.worker):
|
|
104
|
+
raise ValueError("that is you; pick another agent from list_agents()")
|
|
105
|
+
base = self._recipient(to)
|
|
106
|
+
try:
|
|
107
|
+
is_worker = worker.folder(to).is_dir()
|
|
108
|
+
except ValueError:
|
|
109
|
+
is_worker = False
|
|
110
|
+
if is_worker:
|
|
111
|
+
kind = "worker"
|
|
112
|
+
identity = worker.send(to, f"[message from {sender}] {text}", source=sender,
|
|
113
|
+
receipt_owner=self._receipt_owner())
|
|
114
|
+
elif (base / "meta.json").is_file():
|
|
115
|
+
kind = "session"
|
|
116
|
+
identity = f"{time.time_ns()}-{uuid.uuid4().hex}"
|
|
117
|
+
_write(base / "inbox" / f"{identity}.json",
|
|
118
|
+
{"from": sender, "text": text, "time": time.time(),
|
|
119
|
+
"receipt_owner": self._receipt_owner()})
|
|
120
|
+
else:
|
|
121
|
+
raise ValueError(f"no agent named {to!r}; list_agents() shows who is there")
|
|
122
|
+
if receipt:
|
|
123
|
+
return {"id": identity, "to": to, "kind": kind, "status": "queued"}
|
|
124
|
+
return f"sent to {kind} {to}"
|
|
125
|
+
|
|
126
|
+
def _receipt_owner(self) -> str:
|
|
127
|
+
return f"worker:{self.worker}" if self.worker is not None else f"session:{self.me}"
|
|
128
|
+
|
|
129
|
+
def _recipient(self, name: str) -> Path:
|
|
130
|
+
if not isinstance(name, str) or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", name) is None:
|
|
131
|
+
raise ValueError("invalid agent name")
|
|
132
|
+
return self._folder(name)
|
|
133
|
+
|
|
134
|
+
async def message_receipt(self, to: str, message_id: str, kind: str) -> dict:
|
|
135
|
+
"""Look up a sent message with the ID and kind returned by send_message()."""
|
|
136
|
+
from rlmagent import worker
|
|
137
|
+
|
|
138
|
+
base = self._recipient(to)
|
|
139
|
+
if not isinstance(message_id, str) or re.fullmatch(r"[0-9]+-[0-9a-f]{6,32}", message_id) is None:
|
|
140
|
+
raise ValueError("invalid message ID")
|
|
141
|
+
owner = self._receipt_owner()
|
|
142
|
+
status = "unknown"
|
|
143
|
+
if kind == "worker":
|
|
144
|
+
status = worker.receipt_of(to, message_id, owner=owner)["status"]
|
|
145
|
+
elif kind == "session":
|
|
146
|
+
filename = f"{message_id}.json"
|
|
147
|
+
for directory, state in (("inbox", "queued"), ("read", "read")):
|
|
148
|
+
if (_read(base / directory / filename) or {}).get("receipt_owner") == owner:
|
|
149
|
+
status = state
|
|
150
|
+
break
|
|
151
|
+
else:
|
|
152
|
+
raise ValueError("invalid recipient kind")
|
|
153
|
+
return {"id": message_id, "to": to, "kind": kind, "status": status}
|
|
154
|
+
|
|
155
|
+
async def read_messages(self) -> list[dict]:
|
|
156
|
+
"""Messages other agents sent you since your last read, with their IDs."""
|
|
157
|
+
return self.take()
|
|
158
|
+
|
|
159
|
+
def take(self) -> list[dict]:
|
|
160
|
+
from rlmagent.harness.session.lease import SessionBusy, acquire
|
|
161
|
+
|
|
162
|
+
base = self._folder(self.me)
|
|
163
|
+
inbox = base / "inbox"
|
|
164
|
+
if not inbox.is_dir():
|
|
165
|
+
return []
|
|
166
|
+
try:
|
|
167
|
+
lease = acquire(base, "inbox")
|
|
168
|
+
except SessionBusy:
|
|
169
|
+
return []
|
|
170
|
+
taken = []
|
|
171
|
+
with lease:
|
|
172
|
+
for path in sorted(inbox.glob("*.json"))[:MAX_MESSAGES]:
|
|
173
|
+
read = base / "read" / path.name
|
|
174
|
+
read.parent.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
try:
|
|
176
|
+
path.replace(read)
|
|
177
|
+
except FileNotFoundError:
|
|
178
|
+
continue
|
|
179
|
+
except PermissionError as error:
|
|
180
|
+
if getattr(error, "winerror", None) not in (32, 33):
|
|
181
|
+
raise
|
|
182
|
+
continue # A receipt lookup has the file open on Windows.
|
|
183
|
+
if message := _read(read):
|
|
184
|
+
message.pop("receipt_owner", None)
|
|
185
|
+
taken.append({**message, "id": path.stem})
|
|
186
|
+
return taken
|
|
187
|
+
|
|
188
|
+
async def schedule(self, task: str, at: float | None = None, in_seconds: float | None = None,
|
|
189
|
+
every: float | None = None, worker: str | None = None, *,
|
|
190
|
+
cron: str | None = None, timezone: str | None = None) -> str:
|
|
191
|
+
"""Run task later on a background worker: at a unix time, or in_seconds from now,
|
|
192
|
+
optionally every N seconds. Alternatively use a five-field cron expression and
|
|
193
|
+
an IANA timezone (defaults to UTC). Defaults to this worker when you are one."""
|
|
194
|
+
from rlmagent import worker as workers
|
|
195
|
+
|
|
196
|
+
target = worker or self.worker
|
|
197
|
+
if target is None:
|
|
198
|
+
raise ValueError("this session is not a worker; pass worker=NAME (see list_agents())")
|
|
199
|
+
if sum(value is not None for value in (at, in_seconds, cron)) != 1:
|
|
200
|
+
raise ValueError("give exactly one of at, in_seconds, or cron")
|
|
201
|
+
due = None
|
|
202
|
+
if at is not None:
|
|
203
|
+
due = float(at)
|
|
204
|
+
elif in_seconds is not None:
|
|
205
|
+
due = time.time() + float(in_seconds)
|
|
206
|
+
if every is not None and float(every) < 60:
|
|
207
|
+
raise ValueError("every must be at least 60 seconds")
|
|
208
|
+
job = workers.schedule(target, str(task), due, float(every) if every else None,
|
|
209
|
+
cron=cron, timezone=timezone)
|
|
210
|
+
when = workers.job_time(job)
|
|
211
|
+
return f"job {job['id']} on {target} at {when}" + (f", every {every}s" if every else "")
|
|
212
|
+
|
|
213
|
+
async def heartbeat(self, text: str) -> str:
|
|
214
|
+
"""Report progress on long work; the user sees it while you keep going."""
|
|
215
|
+
from rlmagent import worker
|
|
216
|
+
|
|
217
|
+
text = str(text)[:500]
|
|
218
|
+
if self.worker is not None:
|
|
219
|
+
worker.log_line(self.worker, "progress", text)
|
|
220
|
+
if self.on_progress is not None:
|
|
221
|
+
self.on_progress(text)
|
|
222
|
+
return "noted"
|
|
223
|
+
|
|
224
|
+
def functions(self) -> dict:
|
|
225
|
+
return {"list_agents": self.list_agents, "send_message": self.send_message,
|
|
226
|
+
"read_messages": self.read_messages, "message_receipt": self.message_receipt,
|
|
227
|
+
"schedule": self.schedule,
|
|
228
|
+
"heartbeat": self.heartbeat}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def inbox_note(messages: list[dict]) -> str:
|
|
232
|
+
"""Messages put in front of the model before its next turn."""
|
|
233
|
+
lines = [f"[message from {m.get('from', '?')}] {m.get('text', '')}" for m in messages]
|
|
234
|
+
return "Messages from other agents arrived:\n" + "\n".join(lines)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
__all__ = ["AgentNet", "inbox_note"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Sub-agents: the channel from a kernel to its host, and the tree of agents."""
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Approval for what the sandbox asks the host to change, bound to exactly what runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
from collections.abc import Awaitable, Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from rlmagent.safety import is_dangerous_command, is_safe_command
|
|
12
|
+
|
|
13
|
+
ONCE, ALWAYS, DENY = "once", "always", "deny"
|
|
14
|
+
AUTO = "auto" # allow this one and stop asking for the rest of the session
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Request:
|
|
19
|
+
kind: str # "write", "edit" or "run"
|
|
20
|
+
target: str # the path, or the command for "run"
|
|
21
|
+
payload: tuple = () # what will be written: content, or (old, new)
|
|
22
|
+
|
|
23
|
+
preview: str | None = None
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def digest(self) -> str:
|
|
27
|
+
blob = json.dumps([self.kind, self.target, list(self.payload)], ensure_ascii=False)
|
|
28
|
+
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
|
29
|
+
|
|
30
|
+
def describe(self, limit: int = 2000) -> str:
|
|
31
|
+
if self.kind == "run":
|
|
32
|
+
return f"Run: {self.target}"
|
|
33
|
+
body = self.preview if self.preview is not None else "\n".join(str(p) for p in self.payload)
|
|
34
|
+
if len(body) > limit:
|
|
35
|
+
body = body[:limit] + f"\n... ({len(body) - limit} more characters)"
|
|
36
|
+
return f"{self.kind.capitalize()} {self.target}\n{body}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Refused(Exception):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
Ask = Callable[[Request], Awaitable[str]]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Approver:
|
|
47
|
+
"""`ask`, `auto` or `deny`; `ask` needs a function that shows the request to the user."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, policy: str = "deny", ask: Ask | None = None) -> None:
|
|
50
|
+
self.policy = policy
|
|
51
|
+
self.ask = ask
|
|
52
|
+
self.question = None # async (question, choices) -> answer; None: nobody to ask
|
|
53
|
+
self.one_question = asyncio.Lock()
|
|
54
|
+
self._always: set[tuple[str, str]] = set()
|
|
55
|
+
|
|
56
|
+
async def approve(self, request: Request) -> str:
|
|
57
|
+
"""The digest that may run, or Refused."""
|
|
58
|
+
if request.kind == "run" and is_dangerous_command(request.target):
|
|
59
|
+
raise Refused(f"refused: {request.target!r} is a destructive command")
|
|
60
|
+
if request.kind == "run" and is_safe_command(request.target):
|
|
61
|
+
return request.digest
|
|
62
|
+
if self.policy == "auto" or (request.kind, request.target) in self._always:
|
|
63
|
+
return request.digest
|
|
64
|
+
if self.policy != "ask" or self.ask is None:
|
|
65
|
+
raise Refused(
|
|
66
|
+
f"refused: {request.kind} {request.target} needs approval "
|
|
67
|
+
"(run with --auto-approve, or interactively)"
|
|
68
|
+
)
|
|
69
|
+
answer = await self.ask(request)
|
|
70
|
+
if answer == DENY:
|
|
71
|
+
raise Refused(f"the user refused: {request.kind} {request.target}")
|
|
72
|
+
if answer == ALWAYS:
|
|
73
|
+
self._always.add((request.kind, request.target))
|
|
74
|
+
if answer == AUTO:
|
|
75
|
+
self.policy = "auto"
|
|
76
|
+
return request.digest
|
|
77
|
+
|
|
78
|
+
def toggle_auto(self) -> bool:
|
|
79
|
+
"""Switch between asking and auto-accepting; True when auto-accept is now on."""
|
|
80
|
+
if self.policy == "ask":
|
|
81
|
+
self.policy = "auto"
|
|
82
|
+
elif self.policy == "auto" and self.ask is not None:
|
|
83
|
+
self.policy = "ask"
|
|
84
|
+
return self.policy == "auto"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def check(approved: str, request: Request) -> None:
|
|
88
|
+
"""Refuse when what is about to run is not what was approved."""
|
|
89
|
+
if approved != request.digest:
|
|
90
|
+
raise Refused(f"refused: {request.kind} {request.target} changed after approval")
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""A localhost channel that only this run's kernels can use."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import secrets
|
|
8
|
+
from collections.abc import Awaitable, Callable
|
|
9
|
+
|
|
10
|
+
Handler = Callable[[str, str, dict], Awaitable[object]]
|
|
11
|
+
LINE_LIMIT = 2**26 # 64 MB: a sub-agent may hand back a large value
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def kernel_env(port: int, token: str, agent_id: str, *, can_delegate: bool, is_child: bool) -> dict[str, str]:
|
|
15
|
+
return {
|
|
16
|
+
"RLM_AGENT_BRIDGE_PORT": str(port),
|
|
17
|
+
"RLM_AGENT_BRIDGE_TOKEN": token,
|
|
18
|
+
"RLM_AGENT_ID": agent_id,
|
|
19
|
+
"RLM_AGENT_CAN_DELEGATE": "1" if can_delegate else "0",
|
|
20
|
+
"RLM_AGENT_IS_CHILD": "1" if is_child else "0",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BridgeServer:
|
|
25
|
+
"""Serves kernel requests; the per-agent token alone identifies the caller."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, handler: Handler) -> None:
|
|
28
|
+
self._handler = handler
|
|
29
|
+
self._agents: dict[str, str] = {}
|
|
30
|
+
self._running: set[asyncio.Task] = set()
|
|
31
|
+
self.port = 0
|
|
32
|
+
self._server: asyncio.base_events.Server | None = None
|
|
33
|
+
|
|
34
|
+
async def start(self) -> None:
|
|
35
|
+
self._server = await asyncio.start_server(
|
|
36
|
+
self._serve, "127.0.0.1", 0, limit=LINE_LIMIT
|
|
37
|
+
)
|
|
38
|
+
self.port = self._server.sockets[0].getsockname()[1]
|
|
39
|
+
|
|
40
|
+
def issue(self, agent_id: str) -> str:
|
|
41
|
+
token = secrets.token_hex(16)
|
|
42
|
+
self._agents[token] = agent_id
|
|
43
|
+
return token
|
|
44
|
+
|
|
45
|
+
def revoke(self, token: str) -> None:
|
|
46
|
+
self._agents.pop(token, None)
|
|
47
|
+
|
|
48
|
+
def _caller(self, token: object) -> str | None:
|
|
49
|
+
for known, agent_id in self._agents.items():
|
|
50
|
+
if secrets.compare_digest(str(token), known):
|
|
51
|
+
return agent_id
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
async def _serve(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
55
|
+
try:
|
|
56
|
+
reply = await self._reply(reader)
|
|
57
|
+
if reply is None:
|
|
58
|
+
return # the caller hung up; nobody is left to answer
|
|
59
|
+
writer.write((json.dumps(reply, default=repr) + "\n").encode())
|
|
60
|
+
await writer.drain()
|
|
61
|
+
except ConnectionError:
|
|
62
|
+
pass
|
|
63
|
+
finally:
|
|
64
|
+
writer.close()
|
|
65
|
+
|
|
66
|
+
async def _reply(self, reader: asyncio.StreamReader) -> dict | None:
|
|
67
|
+
try:
|
|
68
|
+
line = await reader.readline()
|
|
69
|
+
except (ValueError, asyncio.LimitOverrunError):
|
|
70
|
+
return {"error": "refused: request too large"}
|
|
71
|
+
try:
|
|
72
|
+
request = json.loads(line)
|
|
73
|
+
except json.JSONDecodeError:
|
|
74
|
+
return {"error": "refused: not a JSON request"}
|
|
75
|
+
if not isinstance(request, dict) or "op" not in request:
|
|
76
|
+
return {"error": "refused: malformed request"}
|
|
77
|
+
caller = self._caller(request.get("token", ""))
|
|
78
|
+
if caller is None:
|
|
79
|
+
return {"error": "refused: bad token"}
|
|
80
|
+
return await self._answer(caller, request, reader)
|
|
81
|
+
|
|
82
|
+
async def _answer(self, caller: str, request: dict, reader: asyncio.StreamReader) -> dict | None:
|
|
83
|
+
"""Run the request; cancel it if the kernel hangs up first."""
|
|
84
|
+
work = asyncio.create_task(
|
|
85
|
+
self._handler(caller, str(request["op"]), dict(request.get("args") or {}))
|
|
86
|
+
)
|
|
87
|
+
self._running.add(work)
|
|
88
|
+
hangup = asyncio.create_task(reader.read(1))
|
|
89
|
+
try:
|
|
90
|
+
await asyncio.wait({work, hangup}, return_when=asyncio.FIRST_COMPLETED)
|
|
91
|
+
if not work.done():
|
|
92
|
+
work.cancel()
|
|
93
|
+
await asyncio.gather(work, return_exceptions=True)
|
|
94
|
+
return None
|
|
95
|
+
try:
|
|
96
|
+
return {"ok": work.result()}
|
|
97
|
+
except Exception as exc: # the kernel sees it as an exception in its cell
|
|
98
|
+
return {"error": str(exc) or type(exc).__name__, "type": type(exc).__name__}
|
|
99
|
+
finally:
|
|
100
|
+
hangup.cancel()
|
|
101
|
+
self._running.discard(work)
|
|
102
|
+
|
|
103
|
+
async def close(self) -> None:
|
|
104
|
+
for work in list(self._running):
|
|
105
|
+
work.cancel()
|
|
106
|
+
if self._server is not None:
|
|
107
|
+
self._server.close()
|
|
108
|
+
await self._server.wait_closed()
|
|
109
|
+
self._server = None
|