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,88 @@
|
|
|
1
|
+
"""Build a shell with a private filesystem and an explicit workspace mount."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from rlmness.environment import execution_environment
|
|
13
|
+
|
|
14
|
+
_SUPERVISE = """
|
|
15
|
+
import json, os, signal, subprocess, sys, threading
|
|
16
|
+
child = subprocess.Popen(json.loads(sys.argv[1]), stdin=subprocess.DEVNULL, start_new_session=True)
|
|
17
|
+
def stop():
|
|
18
|
+
try: os.killpg(child.pid, signal.SIGKILL)
|
|
19
|
+
except ProcessLookupError: pass
|
|
20
|
+
def watch():
|
|
21
|
+
while os.read(0, 4096): pass
|
|
22
|
+
stop()
|
|
23
|
+
threading.Thread(target=watch, daemon=True).start()
|
|
24
|
+
try: code = child.wait()
|
|
25
|
+
finally: stop()
|
|
26
|
+
raise SystemExit(code if code >= 0 else 128-code)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SandboxUnavailable(OSError):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@lru_cache(maxsize=1)
|
|
35
|
+
def _runner():
|
|
36
|
+
if os.name == "nt":
|
|
37
|
+
wsl = Path(os.environ.get("SystemRoot", "C:/Windows")) / "System32/wsl.exe"
|
|
38
|
+
prefix = [str(wsl), "--exec"]
|
|
39
|
+
probe = prefix + ["/usr/bin/bwrap", "--version"]
|
|
40
|
+
elif sys.platform.startswith("linux"):
|
|
41
|
+
bwrap = shutil.which("bwrap")
|
|
42
|
+
if bwrap is None:
|
|
43
|
+
raise SandboxUnavailable("Install Bubblewrap for workspace-isolated shell execution")
|
|
44
|
+
prefix, probe = [], [bwrap, "--version"]
|
|
45
|
+
else:
|
|
46
|
+
raise SandboxUnavailable("Workspace shell isolation requires Linux/Bubblewrap or WSL")
|
|
47
|
+
try:
|
|
48
|
+
answer = subprocess.run(probe, capture_output=True, timeout=30,
|
|
49
|
+
env=execution_environment())
|
|
50
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
51
|
+
raise SandboxUnavailable("Workspace shell isolation is unavailable; configure Bubblewrap "
|
|
52
|
+
"in Linux/WSL or explicitly grant --shell-access host") from error
|
|
53
|
+
if answer.returncode:
|
|
54
|
+
raise SandboxUnavailable("Bubblewrap is unavailable in the selected Linux/WSL environment; "
|
|
55
|
+
"install it or explicitly grant --shell-access host")
|
|
56
|
+
return prefix, probe[len(prefix)]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def plan(cwd, command, *, argv=None, env=None, network=False):
|
|
60
|
+
"""Return a supervised argv. Keep its stdin pipe open for its entire lifetime."""
|
|
61
|
+
prefix, bwrap = _runner()
|
|
62
|
+
workspace = str(Path(cwd).resolve())
|
|
63
|
+
if prefix:
|
|
64
|
+
mapped = subprocess.run(prefix + ["/usr/bin/wslpath", "-a", "-u", workspace],
|
|
65
|
+
capture_output=True, text=True, encoding="utf-8", timeout=15,
|
|
66
|
+
env=execution_environment())
|
|
67
|
+
if mapped.returncode or not mapped.stdout.strip().startswith("/"):
|
|
68
|
+
raise SandboxUnavailable("WSL could not resolve the workspace mount")
|
|
69
|
+
workspace = mapped.stdout.strip()
|
|
70
|
+
options = [bwrap, "--die-with-parent", "--new-session", "--unshare-user",
|
|
71
|
+
"--unshare-pid", "--unshare-ipc", "--unshare-uts", "--cap-drop", "ALL",
|
|
72
|
+
"--ro-bind", "/usr", "/usr", "--ro-bind", "/bin", "/bin",
|
|
73
|
+
"--ro-bind", "/lib", "/lib", "--ro-bind-try", "/lib64", "/lib64",
|
|
74
|
+
"--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--dir", "/tmp/home",
|
|
75
|
+
"--bind", workspace, "/workspace", "--chdir", "/workspace", "--clearenv"]
|
|
76
|
+
if not network:
|
|
77
|
+
options += ["--unshare-net"]
|
|
78
|
+
else:
|
|
79
|
+
for name in ("resolv.conf", "hosts", "nsswitch.conf"):
|
|
80
|
+
options += ["--ro-bind-try", "/etc/" + name, "/etc/" + name]
|
|
81
|
+
options += ["--ro-bind-try", "/etc/ssl/certs", "/etc/ssl/certs"]
|
|
82
|
+
variables = {"PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": "/tmp/home",
|
|
83
|
+
"LANG": "C.UTF-8", "PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1", **(env or {})}
|
|
84
|
+
for key, value in variables.items():
|
|
85
|
+
if value is not None:
|
|
86
|
+
options += ["--setenv", str(key), str(value)]
|
|
87
|
+
options += ["--", *(argv or ["/bin/bash", "--noprofile", "--norc", "-c", command])]
|
|
88
|
+
return [*prefix, "/usr/bin/python3", "-I", "-c", _SUPERVISE, json.dumps(options)]
|
rlmagent/skillset.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Markdown-based skill system: discovery, loading, expansion, and indexing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
# ── constants ──────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
_SKILL_FILE = "SKILL.md"
|
|
14
|
+
_AGENTS_FILE = "AGENTS.md"
|
|
15
|
+
_FRONT_MATTER_FENCE = "---"
|
|
16
|
+
_COMMAND_PREFIX = "/skill:"
|
|
17
|
+
_INVOCATION_TAG = "skill-invocation"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ── diagnostic levels ─────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Severity(Enum):
|
|
24
|
+
"""Non-fatal diagnostic severity."""
|
|
25
|
+
|
|
26
|
+
INFO = "info"
|
|
27
|
+
WARNING = "warning"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ── data models ────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class Skill:
|
|
35
|
+
"""An immutable, loaded skill definition."""
|
|
36
|
+
|
|
37
|
+
name: str
|
|
38
|
+
description: str
|
|
39
|
+
body: str
|
|
40
|
+
source: Path
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def command(self) -> str:
|
|
44
|
+
"""The slash command that invokes this skill."""
|
|
45
|
+
return f"{_COMMAND_PREFIX}{self.name}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class SkillInvocation:
|
|
50
|
+
"""A parsed ``/skill:<name>`` expansion ready for prompt injection."""
|
|
51
|
+
|
|
52
|
+
name: str
|
|
53
|
+
arguments: str
|
|
54
|
+
body: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class SkillDiagnostic:
|
|
59
|
+
"""A non-fatal issue encountered during skill discovery."""
|
|
60
|
+
|
|
61
|
+
severity: Severity
|
|
62
|
+
path: Path
|
|
63
|
+
message: str
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class SkillLoadError(Exception):
|
|
67
|
+
"""Fatal error during skill loading (unreadable file, bad encoding)."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ── front-matter parsing ──────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
_FRONT_MATTER_RE = re.compile(
|
|
73
|
+
r"\A---[ \t]*\n(.*?\n)---[ \t]*\n",
|
|
74
|
+
re.DOTALL,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _parse_front_matter(text: str) -> tuple[dict[str, str], str]:
|
|
79
|
+
"""Split optional YAML front matter from the markdown body."""
|
|
80
|
+
match = _FRONT_MATTER_RE.match(text)
|
|
81
|
+
if match is None:
|
|
82
|
+
return {}, text
|
|
83
|
+
|
|
84
|
+
raw_yaml = match.group(1)
|
|
85
|
+
body = text[match.end():]
|
|
86
|
+
meta: dict[str, str] = {}
|
|
87
|
+
for line in raw_yaml.splitlines():
|
|
88
|
+
line = line.strip()
|
|
89
|
+
if not line or line.startswith("#"):
|
|
90
|
+
continue
|
|
91
|
+
colon = line.find(":")
|
|
92
|
+
if colon < 1:
|
|
93
|
+
continue
|
|
94
|
+
key = line[:colon].strip()
|
|
95
|
+
value = line[colon + 1:].strip()
|
|
96
|
+
# Strip surrounding quotes
|
|
97
|
+
if len(value) >= 2 and value[0] in ('"', "'") and value[-1] == value[0]:
|
|
98
|
+
value = value[1:-1]
|
|
99
|
+
meta[key] = value
|
|
100
|
+
|
|
101
|
+
return meta, body
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _derive_description(body: str) -> str:
|
|
105
|
+
"""Extract the first non-heading, non-empty paragraph as a description."""
|
|
106
|
+
for line in body.splitlines():
|
|
107
|
+
stripped = line.strip()
|
|
108
|
+
if not stripped:
|
|
109
|
+
continue
|
|
110
|
+
if stripped.startswith("#"):
|
|
111
|
+
continue
|
|
112
|
+
if stripped.startswith("---"):
|
|
113
|
+
continue
|
|
114
|
+
return stripped
|
|
115
|
+
return ""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ── single-skill loading ──────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _load_skill_from_dir(skill_dir: Path) -> Skill:
|
|
122
|
+
"""Read ``SKILL.md`` from *skill_dir* and return a ``Skill``."""
|
|
123
|
+
md_path = skill_dir / _SKILL_FILE
|
|
124
|
+
try:
|
|
125
|
+
raw = md_path.read_text(encoding="utf-8")
|
|
126
|
+
except OSError as exc:
|
|
127
|
+
raise SkillLoadError(f"Cannot read {md_path}: {exc}") from exc
|
|
128
|
+
except UnicodeDecodeError as exc:
|
|
129
|
+
raise SkillLoadError(
|
|
130
|
+
f"{md_path} is not valid UTF-8: {exc}"
|
|
131
|
+
) from exc
|
|
132
|
+
|
|
133
|
+
meta, body = _parse_front_matter(raw)
|
|
134
|
+
name = meta.get("name", skill_dir.name)
|
|
135
|
+
description = meta.get("description", "") or _derive_description(body)
|
|
136
|
+
|
|
137
|
+
return Skill(
|
|
138
|
+
name=name,
|
|
139
|
+
description=description,
|
|
140
|
+
body=body,
|
|
141
|
+
source=md_path,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ── multi-directory discovery ─────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _discover_in_directory(
|
|
149
|
+
root: Path,
|
|
150
|
+
) -> tuple[list[Skill], list[SkillDiagnostic]]:
|
|
151
|
+
"""Scan a single resource directory for skills."""
|
|
152
|
+
skills: list[Skill] = []
|
|
153
|
+
diagnostics: list[SkillDiagnostic] = []
|
|
154
|
+
seen_names: dict[str, Path] = {}
|
|
155
|
+
|
|
156
|
+
if not root.is_dir():
|
|
157
|
+
return skills, diagnostics
|
|
158
|
+
|
|
159
|
+
for child in sorted(root.iterdir()):
|
|
160
|
+
# Skip AGENTS.md
|
|
161
|
+
if child.name == _AGENTS_FILE:
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
# Bare .md files are not skills
|
|
165
|
+
if child.is_file() and child.suffix.lower() == ".md":
|
|
166
|
+
diagnostics.append(SkillDiagnostic(
|
|
167
|
+
severity=Severity.INFO,
|
|
168
|
+
path=child,
|
|
169
|
+
message=(
|
|
170
|
+
f"Bare markdown file ignored. To make it a skill, "
|
|
171
|
+
f"move it to {child.stem}/{_SKILL_FILE}"
|
|
172
|
+
),
|
|
173
|
+
))
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
# Only directories with SKILL.md
|
|
177
|
+
if not child.is_dir():
|
|
178
|
+
continue
|
|
179
|
+
if not (child / _SKILL_FILE).is_file():
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
skill = _load_skill_from_dir(child)
|
|
184
|
+
except SkillLoadError as exc:
|
|
185
|
+
diagnostics.append(SkillDiagnostic(
|
|
186
|
+
severity=Severity.WARNING,
|
|
187
|
+
path=child,
|
|
188
|
+
message=str(exc),
|
|
189
|
+
))
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
if skill.name in seen_names:
|
|
193
|
+
diagnostics.append(SkillDiagnostic(
|
|
194
|
+
severity=Severity.WARNING,
|
|
195
|
+
path=child,
|
|
196
|
+
message=(
|
|
197
|
+
f"Duplicate skill name {skill.name!r} in same directory; "
|
|
198
|
+
f"already defined at {seen_names[skill.name]}"
|
|
199
|
+
),
|
|
200
|
+
))
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
seen_names[skill.name] = child
|
|
204
|
+
skills.append(skill)
|
|
205
|
+
|
|
206
|
+
return skills, diagnostics
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def load_skills_with_diagnostics(
|
|
210
|
+
resource_dirs: Sequence[Path],
|
|
211
|
+
) -> tuple[list[Skill], list[SkillDiagnostic]]:
|
|
212
|
+
"""Load skills from multiple resource directories with diagnostics."""
|
|
213
|
+
combined: dict[str, Skill] = {}
|
|
214
|
+
all_diagnostics: list[SkillDiagnostic] = []
|
|
215
|
+
|
|
216
|
+
for root in resource_dirs:
|
|
217
|
+
skills, diags = _discover_in_directory(root)
|
|
218
|
+
all_diagnostics.extend(diags)
|
|
219
|
+
for skill in skills:
|
|
220
|
+
if skill.name not in combined:
|
|
221
|
+
combined[skill.name] = skill
|
|
222
|
+
|
|
223
|
+
return list(combined.values()), all_diagnostics
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def load_skills(resource_dirs: Sequence[Path]) -> list[Skill]:
|
|
227
|
+
"""Load skills from resource directories, discarding diagnostics."""
|
|
228
|
+
skills, _ = load_skills_with_diagnostics(resource_dirs)
|
|
229
|
+
return skills
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ── command expansion ──────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def expand_command(
|
|
236
|
+
text: str,
|
|
237
|
+
skills: dict[str, Skill],
|
|
238
|
+
) -> str | None:
|
|
239
|
+
"""Expand a ``/skill:<name>`` command into an XML invocation block."""
|
|
240
|
+
if not text.startswith(_COMMAND_PREFIX):
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
rest = text[len(_COMMAND_PREFIX):]
|
|
244
|
+
parts = rest.split(None, 1)
|
|
245
|
+
name = parts[0] if parts else rest
|
|
246
|
+
arguments = parts[1] if len(parts) > 1 else ""
|
|
247
|
+
|
|
248
|
+
if name not in skills:
|
|
249
|
+
raise KeyError(f"Unknown skill: {name!r}")
|
|
250
|
+
|
|
251
|
+
skill = skills[name]
|
|
252
|
+
return format_invocation(SkillInvocation(
|
|
253
|
+
name=name,
|
|
254
|
+
arguments=arguments,
|
|
255
|
+
body=skill.body,
|
|
256
|
+
))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ── invocation formatting / parsing ────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
_INVOCATION_RE = re.compile(
|
|
262
|
+
rf"<{_INVOCATION_TAG}\s+name=\"([^\"]+)\"(?:\s+arguments=\"([^\"]*)\")?>\n"
|
|
263
|
+
rf"(.*?)"
|
|
264
|
+
rf"</{_INVOCATION_TAG}>",
|
|
265
|
+
re.DOTALL,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def format_invocation(invocation: SkillInvocation) -> str:
|
|
270
|
+
"""Render a ``SkillInvocation`` as an XML-like block."""
|
|
271
|
+
attrs = f'name="{invocation.name}"'
|
|
272
|
+
if invocation.arguments:
|
|
273
|
+
attrs += f' arguments="{invocation.arguments}"'
|
|
274
|
+
return (
|
|
275
|
+
f"<{_INVOCATION_TAG} {attrs}>\n"
|
|
276
|
+
f"{invocation.body}\n"
|
|
277
|
+
f"</{_INVOCATION_TAG}>"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def parse_invocation(text: str) -> SkillInvocation | None:
|
|
282
|
+
"""Parse an XML-like skill invocation block back into a ``SkillInvocation``."""
|
|
283
|
+
match = _INVOCATION_RE.search(text)
|
|
284
|
+
if match is None:
|
|
285
|
+
return None
|
|
286
|
+
return SkillInvocation(
|
|
287
|
+
name=match.group(1),
|
|
288
|
+
arguments=match.group(2) or "",
|
|
289
|
+
body=match.group(3).strip(),
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ── skill index for system prompts ────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def build_skill_index(skills: Sequence[Skill]) -> str:
|
|
297
|
+
"""Build a concise skill listing suitable for inclusion in a system prompt."""
|
|
298
|
+
if not skills:
|
|
299
|
+
return ""
|
|
300
|
+
lines = [
|
|
301
|
+
f" {skill.command} — {skill.description}"
|
|
302
|
+
for skill in sorted(skills, key=lambda s: s.name)
|
|
303
|
+
]
|
|
304
|
+
header = "Available skills (invoke with /skill:<name>):"
|
|
305
|
+
return header + "\n" + "\n".join(lines)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
__all__ = [
|
|
309
|
+
"Severity",
|
|
310
|
+
"Skill",
|
|
311
|
+
"SkillDiagnostic",
|
|
312
|
+
"SkillInvocation",
|
|
313
|
+
"SkillLoadError",
|
|
314
|
+
"build_skill_index",
|
|
315
|
+
"expand_command",
|
|
316
|
+
"format_invocation",
|
|
317
|
+
"load_skills",
|
|
318
|
+
"load_skills_with_diagnostics",
|
|
319
|
+
"parse_invocation",
|
|
320
|
+
]
|
rlmagent/steering.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Accepted instructions and model-boundary delivery receipts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import json
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
from rlmagent.refinement.store import _lock
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Steering:
|
|
16
|
+
def __init__(self, path: Path | None):
|
|
17
|
+
self.path = path
|
|
18
|
+
self.memory = {}
|
|
19
|
+
self._mutex = threading.RLock()
|
|
20
|
+
|
|
21
|
+
def _read(self):
|
|
22
|
+
if self.path is None:
|
|
23
|
+
with self._mutex:
|
|
24
|
+
return copy.deepcopy(self.memory)
|
|
25
|
+
try:
|
|
26
|
+
return json.loads(self.path.read_text(encoding="utf-8"))
|
|
27
|
+
except FileNotFoundError:
|
|
28
|
+
return {}
|
|
29
|
+
|
|
30
|
+
def _write(self, records):
|
|
31
|
+
if self.path is None:
|
|
32
|
+
with self._mutex:
|
|
33
|
+
self.memory = records
|
|
34
|
+
return
|
|
35
|
+
temporary = self.path.with_suffix(f".{uuid4().hex}.tmp")
|
|
36
|
+
try:
|
|
37
|
+
temporary.write_text(json.dumps(records), encoding="utf-8")
|
|
38
|
+
temporary.replace(self.path)
|
|
39
|
+
finally:
|
|
40
|
+
temporary.unlink(missing_ok=True)
|
|
41
|
+
|
|
42
|
+
def _guard(self):
|
|
43
|
+
return self._mutex if self.path is None else _lock(self.path.with_suffix(".lock"))
|
|
44
|
+
|
|
45
|
+
def accept(self, text: str, identity: str | None = None, target: str = "root", *,
|
|
46
|
+
once: bool = False, allow_new: bool = True):
|
|
47
|
+
identity = identity or uuid4().hex
|
|
48
|
+
if (
|
|
49
|
+
not isinstance(identity, str)
|
|
50
|
+
or not 1 <= len(identity) <= 128
|
|
51
|
+
or not isinstance(target, str)
|
|
52
|
+
or not 1 <= len(target) <= 256
|
|
53
|
+
or not isinstance(text, str)
|
|
54
|
+
or not text.strip()
|
|
55
|
+
or len(text) > 65536
|
|
56
|
+
):
|
|
57
|
+
raise ValueError("Invalid steering identity, target or text")
|
|
58
|
+
|
|
59
|
+
def update():
|
|
60
|
+
records = self._read()
|
|
61
|
+
if identity in records:
|
|
62
|
+
previous = records[identity]
|
|
63
|
+
if (previous["text"] != text or previous["target"] != target
|
|
64
|
+
or previous.get("once", False) != once):
|
|
65
|
+
raise ValueError("messageId was already used with different content or target")
|
|
66
|
+
return self.receipt(identity, previous)
|
|
67
|
+
if not allow_new:
|
|
68
|
+
raise ValueError("child is idle or finished; use send for a follow-up turn")
|
|
69
|
+
if len(records) >= 10000:
|
|
70
|
+
raise ValueError("Steering identity limit reached")
|
|
71
|
+
records[identity] = {
|
|
72
|
+
"text": text,
|
|
73
|
+
"target": target,
|
|
74
|
+
"accepted_at": time.time(),
|
|
75
|
+
"deliveries": {},
|
|
76
|
+
}
|
|
77
|
+
if once:
|
|
78
|
+
records[identity]["once"] = True
|
|
79
|
+
self._write(records)
|
|
80
|
+
return self.receipt(identity, records[identity])
|
|
81
|
+
|
|
82
|
+
with self._guard():
|
|
83
|
+
return update()
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def receipt(identity, record):
|
|
87
|
+
return {
|
|
88
|
+
"messageId": identity,
|
|
89
|
+
"target": record["target"],
|
|
90
|
+
"status": ("consumed" if record["deliveries"] else
|
|
91
|
+
"expired" if "expired_at" in record else "accepted"),
|
|
92
|
+
"deliveries": dict(record["deliveries"]),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
def receipts(self, *, target: str | None = None):
|
|
96
|
+
return [self.receipt(identity, record) for identity, record in self._read().items()
|
|
97
|
+
if target is None or record["target"] == target]
|
|
98
|
+
|
|
99
|
+
def context(self, agent: str, *, root: bool = False, child: str | None = None):
|
|
100
|
+
targets = {agent}
|
|
101
|
+
if root:
|
|
102
|
+
targets.add("root")
|
|
103
|
+
if child is not None:
|
|
104
|
+
targets.add(child)
|
|
105
|
+
instructions, pending = [], []
|
|
106
|
+
for identity, record in self._read().items():
|
|
107
|
+
target = record["target"]
|
|
108
|
+
if target not in targets or "expired_at" in record:
|
|
109
|
+
continue
|
|
110
|
+
delivered = (child if target == child else agent) in record["deliveries"]
|
|
111
|
+
if not delivered or not record.get("once", False):
|
|
112
|
+
instructions.append(record["text"])
|
|
113
|
+
if not delivered:
|
|
114
|
+
pending.append(identity)
|
|
115
|
+
return "\n\n".join(instructions), pending
|
|
116
|
+
|
|
117
|
+
def consumed(self, identities, agent: str, request: str | None, *, child: str | None = None):
|
|
118
|
+
if not identities:
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
def update():
|
|
122
|
+
records = self._read()
|
|
123
|
+
for identity in identities:
|
|
124
|
+
record = records[identity]
|
|
125
|
+
record["deliveries"].setdefault(
|
|
126
|
+
child if record["target"] == child else agent,
|
|
127
|
+
{
|
|
128
|
+
"requestId": request,
|
|
129
|
+
"consumed_at": time.time(),
|
|
130
|
+
},
|
|
131
|
+
)
|
|
132
|
+
self._write(records)
|
|
133
|
+
|
|
134
|
+
with self._guard():
|
|
135
|
+
update()
|
|
136
|
+
|
|
137
|
+
def expire_child(self, child: str) -> None:
|
|
138
|
+
"""Close undelivered one-shot instructions at the end of a child turn."""
|
|
139
|
+
with self._guard():
|
|
140
|
+
records = self._read()
|
|
141
|
+
pending = [record for record in records.values()
|
|
142
|
+
if record["target"] == child and record.get("once")
|
|
143
|
+
and not record["deliveries"] and "expired_at" not in record]
|
|
144
|
+
if pending:
|
|
145
|
+
finished = time.time()
|
|
146
|
+
for record in pending:
|
|
147
|
+
record["expired_at"] = finished
|
|
148
|
+
self._write(records)
|
rlmagent/stops.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Why a run stopped, as one word a training or evaluation pipeline can act on.
|
|
2
|
+
|
|
3
|
+
done the agent answered
|
|
4
|
+
max_calls the tree used up its model calls
|
|
5
|
+
max_cost the tree used up its spend
|
|
6
|
+
max_tokens the tree used up its completion or prompt tokens
|
|
7
|
+
max_seconds the run used up its time
|
|
8
|
+
agent_tokens one agent used up its completion tokens
|
|
9
|
+
compaction_failed the context was full and could not be compacted
|
|
10
|
+
no_answer the run ended without a usable answer (an empty or failed final reply)
|
|
11
|
+
cancelled the user or client stopped it
|
|
12
|
+
error anything else, with the message beside it
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
_LIMITS = (
|
|
18
|
+
("calls reaches the limit", "max_calls"),
|
|
19
|
+
("cost ", "max_cost"),
|
|
20
|
+
("completion tokens", "max_tokens"),
|
|
21
|
+
("prompt tokens", "max_tokens"),
|
|
22
|
+
("tokens", "max_tokens"),
|
|
23
|
+
("s reaches the limit", "max_seconds"),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def classify(refusal: str | None, final_text: str, error: str | None,
|
|
28
|
+
cancelled: bool = False) -> str:
|
|
29
|
+
if cancelled or (error or "").lower() in ("cancelled", "aborted"):
|
|
30
|
+
return "cancelled"
|
|
31
|
+
text = f"{refusal or ''} {error or ''}"
|
|
32
|
+
if "reached its limit of" in text and "completion tokens" in text:
|
|
33
|
+
return "agent_tokens"
|
|
34
|
+
if refusal:
|
|
35
|
+
for needle, reason in _LIMITS:
|
|
36
|
+
if needle in refusal:
|
|
37
|
+
return reason
|
|
38
|
+
return "max_calls"
|
|
39
|
+
if "compaction" in (error or "").lower():
|
|
40
|
+
return "compaction_failed"
|
|
41
|
+
if error:
|
|
42
|
+
return "error"
|
|
43
|
+
if not final_text.strip():
|
|
44
|
+
return "no_answer"
|
|
45
|
+
return "done"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def of_session(session, refusal: str | None) -> dict:
|
|
49
|
+
"""The stop reason of the session's last turn, with the message that explains it."""
|
|
50
|
+
last = next((m for m in reversed(list(session.transcript))
|
|
51
|
+
if getattr(m, "role", "") == "assistant"), None)
|
|
52
|
+
text = getattr(last, "text", "") or ""
|
|
53
|
+
error = getattr(last, "error_message", None)
|
|
54
|
+
stop = getattr(last, "stop_reason", None)
|
|
55
|
+
reason = classify(refusal, text, error, cancelled=stop == "aborted")
|
|
56
|
+
typed = getattr(getattr(session, "backend", None), "last_stop_reason", None)
|
|
57
|
+
if typed and stop != "aborted":
|
|
58
|
+
reason = typed
|
|
59
|
+
return {"stop_reason": reason, "stop_detail": refusal or error or ""}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
__all__ = ["classify", "of_session"]
|