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/planning.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Plan mode: a read-only stance for the agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
|
|
8
|
+
from rlmagent.safety import is_safe_command
|
|
9
|
+
from rlmagent.harness.contracts.tooling import (
|
|
10
|
+
CancelToken,
|
|
11
|
+
ProgressNotifier,
|
|
12
|
+
ToolOutcome,
|
|
13
|
+
ToolSpec,
|
|
14
|
+
)
|
|
15
|
+
from rlmagent.harness.contracts.values import JValue
|
|
16
|
+
|
|
17
|
+
#: Tools removed entirely while plan mode is active.
|
|
18
|
+
#: The python kernel can write anything, so it cannot be limited to reading.
|
|
19
|
+
MUTATING_TOOLS = frozenset({"Write", "Edit", "ApplyPatch", "python"})
|
|
20
|
+
|
|
21
|
+
#: Tool whose commands are filtered rather than removed.
|
|
22
|
+
_SHELL_TOOL = "Bash"
|
|
23
|
+
|
|
24
|
+
_REFUSAL = (
|
|
25
|
+
"Plan mode is active: {tool} may only run read-only commands. "
|
|
26
|
+
"Refused: {command!r}. Present your plan and ask the user to leave "
|
|
27
|
+
"plan mode before running this."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
PLAN_MODE_INSTRUCTIONS = """## Plan mode
|
|
31
|
+
|
|
32
|
+
Plan mode is active. Research the codebase and produce a plan — do not \
|
|
33
|
+
change anything.
|
|
34
|
+
|
|
35
|
+
- Read files, inspect the tree, and run read-only shell commands freely.
|
|
36
|
+
- Do not create, modify, or delete files, and do not run commands with side \
|
|
37
|
+
effects. The tools to do so are unavailable or will refuse.
|
|
38
|
+
- Finish by presenting a concrete, ordered implementation plan: the files to \
|
|
39
|
+
touch, the change in each, and how the result gets verified.
|
|
40
|
+
- Ask the user to leave plan mode (`/plan off`) before implementing anything."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _guard_shell(tool: ToolSpec) -> ToolSpec:
|
|
44
|
+
"""Wrap a shell tool so only read-only commands reach the real handler."""
|
|
45
|
+
inner = tool.run
|
|
46
|
+
|
|
47
|
+
async def run(
|
|
48
|
+
tool_call_id: str,
|
|
49
|
+
arguments: Mapping[str, JValue],
|
|
50
|
+
signal: CancelToken | None = None,
|
|
51
|
+
on_update: ProgressNotifier | None = None,
|
|
52
|
+
) -> ToolOutcome:
|
|
53
|
+
command = arguments.get("command")
|
|
54
|
+
if not isinstance(command, str) or not is_safe_command(command):
|
|
55
|
+
return ToolOutcome(
|
|
56
|
+
content=_REFUSAL.format(tool=tool.name, command=command),
|
|
57
|
+
details={"error": "plan_mode"},
|
|
58
|
+
)
|
|
59
|
+
return await inner(tool_call_id, arguments, signal, on_update)
|
|
60
|
+
|
|
61
|
+
return replace(tool, run=run)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def restrict_tools(tools: Sequence[ToolSpec]) -> list[ToolSpec]:
|
|
65
|
+
"""Return *tools* with mutating entries dropped and the shell guarded."""
|
|
66
|
+
restricted: list[ToolSpec] = []
|
|
67
|
+
for tool in tools:
|
|
68
|
+
if tool.name in MUTATING_TOOLS:
|
|
69
|
+
continue
|
|
70
|
+
restricted.append(_guard_shell(tool) if tool.name == _SHELL_TOOL else tool)
|
|
71
|
+
return restricted
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def plan_system_prompt(system: str) -> str:
|
|
75
|
+
"""Append the plan-mode instruction block to a base system prompt."""
|
|
76
|
+
return f"{system}\n\n{PLAN_MODE_INSTRUCTIONS}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
__all__ = [
|
|
80
|
+
"MUTATING_TOOLS",
|
|
81
|
+
"PLAN_MODE_INSTRUCTIONS",
|
|
82
|
+
"plan_system_prompt",
|
|
83
|
+
"restrict_tools",
|
|
84
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The agent's playbook: rules, memories, skills and roles it keeps between runs."""
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Check proposed playbook edits and apply the valid ones; build the edits that undo a change."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import asdict
|
|
9
|
+
|
|
10
|
+
from rlmagent.playbook.store import KINDS, Entry, Playbook
|
|
11
|
+
|
|
12
|
+
ACTIONS = ("create", "update", "delete")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate(edit: dict) -> str | None:
|
|
16
|
+
action, kind = edit.get("action"), edit.get("kind")
|
|
17
|
+
if action not in ACTIONS:
|
|
18
|
+
return f"action must be one of {ACTIONS}"
|
|
19
|
+
if not str(edit.get("id") or "").strip():
|
|
20
|
+
return "id is required"
|
|
21
|
+
if action == "delete":
|
|
22
|
+
return None
|
|
23
|
+
if kind not in KINDS:
|
|
24
|
+
return f"kind must be one of {KINDS}"
|
|
25
|
+
life = edit.get("expires_after_tasks")
|
|
26
|
+
if life is not None and (not isinstance(life, int) or isinstance(life, bool) or life < 1):
|
|
27
|
+
return "expires_after_tasks must be a positive whole number"
|
|
28
|
+
if not str(edit.get("title") or "").strip() or not str(edit.get("content") or "").strip():
|
|
29
|
+
return "title and content are required"
|
|
30
|
+
if kind == "skill":
|
|
31
|
+
name = edit.get("callable")
|
|
32
|
+
if not name:
|
|
33
|
+
return "a skill needs `callable`: the function its code defines"
|
|
34
|
+
try:
|
|
35
|
+
tree = ast.parse(edit["content"])
|
|
36
|
+
except SyntaxError as exc:
|
|
37
|
+
return f"skill content is not valid Python: {exc.msg}"
|
|
38
|
+
defined = {n.name for n in tree.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))}
|
|
39
|
+
if name not in defined:
|
|
40
|
+
return f"skill content never defines {name}()"
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def apply_edits(pb: Playbook, edits: list[dict], *, baseline: dict[str, int], scope: str,
|
|
45
|
+
trigger: str, summary: str, rationale: str = "",
|
|
46
|
+
read_only: Playbook | None = None) -> dict:
|
|
47
|
+
"""Apply what is valid; record every edit, applied or not, in `pb.history`."""
|
|
48
|
+
changes = []
|
|
49
|
+
for edit in edits:
|
|
50
|
+
eid = str(edit.get("id") or "").removeprefix("local:")
|
|
51
|
+
before = pb.entries.get(eid)
|
|
52
|
+
error = validate(edit)
|
|
53
|
+
if error is None:
|
|
54
|
+
error = _conflict(edit, eid, before, baseline, read_only)
|
|
55
|
+
change = {"action": edit.get("action"), "id": eid, "kind": edit.get("kind"),
|
|
56
|
+
"applied": error is None, "error": error,
|
|
57
|
+
"before": asdict(before) if before else None, "after": None}
|
|
58
|
+
if error is None:
|
|
59
|
+
if edit["action"] == "delete":
|
|
60
|
+
del pb.entries[eid]
|
|
61
|
+
else:
|
|
62
|
+
entry = Entry(
|
|
63
|
+
id=eid, kind=edit["kind"], title=edit["title"], content=edit["content"],
|
|
64
|
+
scope=scope, callable=edit.get("callable"),
|
|
65
|
+
version=(before.version + 1) if before else 1, updated=time.time(),
|
|
66
|
+
expires_after_tasks=edit.get("expires_after_tasks"), made_at_task=pb.tasks,
|
|
67
|
+
)
|
|
68
|
+
pb.entries[eid] = entry
|
|
69
|
+
change["after"] = asdict(entry)
|
|
70
|
+
changes.append(change)
|
|
71
|
+
event = {"id": uuid.uuid4().hex[:8], "trigger": trigger, "scope": scope, "summary": summary,
|
|
72
|
+
"rationale": rationale, "time": time.time(), "changes": changes}
|
|
73
|
+
pb.history.append(event)
|
|
74
|
+
return event
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _conflict(edit, eid, before, baseline, read_only) -> str | None:
|
|
78
|
+
action = edit["action"]
|
|
79
|
+
if read_only is not None and eid in read_only.entries and eid not in baseline and before is None:
|
|
80
|
+
return "shared entries are read-only here; create a local entry instead"
|
|
81
|
+
if action == "create" and before is not None:
|
|
82
|
+
return "an entry with this id already exists"
|
|
83
|
+
if action != "create" and before is None:
|
|
84
|
+
return "no entry with this id"
|
|
85
|
+
if before is not None and eid in baseline and baseline[eid] != before.version:
|
|
86
|
+
return "entry changed while this edit was being planned"
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def reverse_edits(event: dict) -> list[dict]:
|
|
91
|
+
"""Edits that undo the applied changes of `event`, newest first."""
|
|
92
|
+
out = []
|
|
93
|
+
for change in reversed(event["changes"]):
|
|
94
|
+
if not change["applied"]:
|
|
95
|
+
continue
|
|
96
|
+
if change["action"] == "create":
|
|
97
|
+
out.append({"action": "delete", "id": change["id"]})
|
|
98
|
+
elif change["action"] == "update":
|
|
99
|
+
out.append({"action": "update", **_as_edit(change["before"])})
|
|
100
|
+
else:
|
|
101
|
+
out.append({"action": "create", **_as_edit(change["before"])})
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _as_edit(entry: dict) -> dict:
|
|
106
|
+
return {k: entry[k] for k in ("id", "kind", "title", "content", "callable")}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""The short view of the playbook the agent sees in its conversation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
from rlmagent.playbook.store import KINDS, Entry
|
|
10
|
+
|
|
11
|
+
PER_KIND = 5
|
|
12
|
+
WIDTH = 200
|
|
13
|
+
|
|
14
|
+
_HEADER = """[Playbook] Entries you (or earlier sessions) kept for later. Follow the rules, \
|
|
15
|
+
use the memories. In the python tool: `await playbook("id")` shows an entry in full, \
|
|
16
|
+
`fn = await skill("id")` loads a skill as a function, and `await improve("what to \
|
|
17
|
+
keep")` asks for the playbook to be updated from this conversation once the turn ends."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _terms(text: str) -> set[str]:
|
|
21
|
+
return set(re.findall(r"[a-z0-9_]{3,}", text.lower()))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _weights(goal: str, recent: list[str]) -> dict[str, float]:
|
|
25
|
+
"""Goal words weigh 3; recent messages weigh from 2 (newest) down to 1 (oldest)."""
|
|
26
|
+
weights: dict[str, float] = {}
|
|
27
|
+
for i, text in enumerate(recent):
|
|
28
|
+
w = 1 + (i / max(len(recent) - 1, 1))
|
|
29
|
+
for term in _terms(text):
|
|
30
|
+
weights[term] = max(weights.get(term, 0), w)
|
|
31
|
+
for term in _terms(goal):
|
|
32
|
+
weights[term] = 3.0
|
|
33
|
+
return weights
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _score(entry_terms: set[str], weights: dict[str, float], rarity: dict[str, float]) -> float:
|
|
37
|
+
return sum(weights[t] * rarity.get(t, 1.0) for t in entry_terms if t in weights)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def render(entries: list[Entry], goal: str = "", recent: list[str] | None = None,
|
|
41
|
+
per_kind: int = PER_KIND) -> str:
|
|
42
|
+
weights = _weights(goal, recent or [])
|
|
43
|
+
terms = {e.id: _terms(f"{e.id} {e.title} {e.content}") for e in entries}
|
|
44
|
+
counts: dict[str, int] = {}
|
|
45
|
+
for ts in terms.values():
|
|
46
|
+
for t in ts:
|
|
47
|
+
counts[t] = counts.get(t, 0) + 1
|
|
48
|
+
rarity = {t: math.log(1 + len(entries) / c) for t, c in counts.items()}
|
|
49
|
+
lines = [_HEADER]
|
|
50
|
+
for kind in KINDS:
|
|
51
|
+
chosen = [e for e in entries if e.kind == kind]
|
|
52
|
+
chosen.sort(key=lambda e: (-_score(terms[e.id], weights, rarity), -e.updated))
|
|
53
|
+
for e in chosen[:per_kind]:
|
|
54
|
+
text = " ".join(f"{e.title}: {e.content}".split())
|
|
55
|
+
if len(text) > WIDTH:
|
|
56
|
+
text = text[: WIDTH - 1] + "…"
|
|
57
|
+
lines.append(f"- [{kind}] {e.id}: {text}")
|
|
58
|
+
return "\n".join(lines)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def fingerprint(entries: list[Entry]) -> str:
|
|
62
|
+
key = "|".join(sorted(f"{e.id}:{e.version}" for e in entries))
|
|
63
|
+
return hashlib.sha1(key.encode()).hexdigest()[:12]
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Runs playbook updates for one session: when asked, between turns, with undo."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from rlmagent.playbook.apply import apply_edits, reverse_edits
|
|
12
|
+
from rlmagent.playbook.digest import fingerprint, render
|
|
13
|
+
|
|
14
|
+
from rlmagent.playbook.plan import (
|
|
15
|
+
AUTO_NOTE,
|
|
16
|
+
CHECK_CHARS,
|
|
17
|
+
CHECK_SYSTEM,
|
|
18
|
+
SYSTEM,
|
|
19
|
+
build_request,
|
|
20
|
+
parse_check,
|
|
21
|
+
parse_proposal,
|
|
22
|
+
)
|
|
23
|
+
from rlmagent.playbook.store import Playbook, load, merged, save
|
|
24
|
+
|
|
25
|
+
#: How long a memory saved without being asked for lasts, in finished tasks. It
|
|
26
|
+
#: was written from one moment of the work, which is what makes it go stale.
|
|
27
|
+
AUTO_MEMORY_TASKS = 3
|
|
28
|
+
#: Failed tool calls in one task that are worth a look for something to keep.
|
|
29
|
+
TROUBLE_CALLS = 3
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Improver:
|
|
33
|
+
def __init__(self, session, local_path: Path | None, shared_path: Path | None) -> None:
|
|
34
|
+
self.writable = True
|
|
35
|
+
self.session = session
|
|
36
|
+
self.paths = {"local": local_path, "global": shared_path}
|
|
37
|
+
self.local = load(local_path)
|
|
38
|
+
self.shared = load(shared_path)
|
|
39
|
+
self._pending: list[tuple[str | None, bool, str]] = []
|
|
40
|
+
self._shown: str | None = None
|
|
41
|
+
self.auto = True
|
|
42
|
+
self.every = 5
|
|
43
|
+
self.cooldown = 900.0
|
|
44
|
+
self._counted = 0
|
|
45
|
+
self._finished = 0
|
|
46
|
+
self._last_check = float("-inf")
|
|
47
|
+
self._compacted = False
|
|
48
|
+
# When set, proposals from automatic checks wait in held for /improve accept.
|
|
49
|
+
self.hold_auto = False
|
|
50
|
+
self.held: list[dict] = []
|
|
51
|
+
|
|
52
|
+
def _store(self, scope: str) -> Playbook:
|
|
53
|
+
if self.paths[scope] is not None:
|
|
54
|
+
fresh = load(self.paths[scope])
|
|
55
|
+
setattr(self, "local" if scope == "local" else "shared", fresh)
|
|
56
|
+
return self.local if scope == "local" else self.shared
|
|
57
|
+
|
|
58
|
+
def entries(self) -> list:
|
|
59
|
+
return merged(self.local, self.shared)
|
|
60
|
+
|
|
61
|
+
def entry(self, entry_id: str) -> dict:
|
|
62
|
+
for e in self.entries():
|
|
63
|
+
if e.id == entry_id:
|
|
64
|
+
return asdict(e)
|
|
65
|
+
raise KeyError(f"no playbook entry {entry_id!r}")
|
|
66
|
+
|
|
67
|
+
def request(self, instructions: str | None, shared: bool = False, trigger: str = "agent") -> None:
|
|
68
|
+
if not self.writable:
|
|
69
|
+
raise RuntimeError("playbook is read-only")
|
|
70
|
+
self._pending.append((instructions, shared, trigger))
|
|
71
|
+
|
|
72
|
+
async def run_pending(self) -> list[str]:
|
|
73
|
+
notes = []
|
|
74
|
+
while self._pending:
|
|
75
|
+
instructions, shared, trigger = self._pending.pop(0)
|
|
76
|
+
notes.append(await self.improve(instructions, shared=shared, trigger=trigger))
|
|
77
|
+
return notes
|
|
78
|
+
|
|
79
|
+
async def after_turn(self) -> None:
|
|
80
|
+
"""Every `every` finished tasks (at most once per `cooldown` seconds), check.
|
|
81
|
+
|
|
82
|
+
Tasks rather than model turns: one task can take dozens of turns, and a
|
|
83
|
+
count of those lands in the middle of work that has nothing to teach yet.
|
|
84
|
+
A task that went badly is checked sooner, since that is where the lessons are.
|
|
85
|
+
"""
|
|
86
|
+
if not self.auto or time.monotonic() - self._last_check < self.cooldown:
|
|
87
|
+
return
|
|
88
|
+
evidence = getattr(self.session, "evidence", None)
|
|
89
|
+
if evidence is not None and evidence.last_failed >= TROUBLE_CALLS:
|
|
90
|
+
self._counted = self._finished
|
|
91
|
+
await self._auto("auto:trouble")
|
|
92
|
+
elif self._finished - self._counted >= self.every:
|
|
93
|
+
self._counted = self._finished
|
|
94
|
+
await self._auto("auto:tasks")
|
|
95
|
+
|
|
96
|
+
def compacted(self) -> None:
|
|
97
|
+
"""Note a compaction; the check runs at the next turn boundary, never inside a turn."""
|
|
98
|
+
self._compacted = True
|
|
99
|
+
|
|
100
|
+
def drop_pending(self) -> None:
|
|
101
|
+
self._pending.clear()
|
|
102
|
+
self._compacted = False
|
|
103
|
+
|
|
104
|
+
async def at_boundary(self) -> None:
|
|
105
|
+
self._finished += 1
|
|
106
|
+
await self._task_finished()
|
|
107
|
+
await self.run_pending()
|
|
108
|
+
if self._compacted:
|
|
109
|
+
self._compacted = False
|
|
110
|
+
if self.auto:
|
|
111
|
+
await self._auto("auto:compaction")
|
|
112
|
+
await self.after_turn()
|
|
113
|
+
|
|
114
|
+
async def _task_finished(self) -> None:
|
|
115
|
+
"""Count the task in the session's playbook, and drop what has now gone stale."""
|
|
116
|
+
if not self.writable:
|
|
117
|
+
return
|
|
118
|
+
target = self._store("local")
|
|
119
|
+
target.tasks += 1
|
|
120
|
+
save(self.paths["local"], target)
|
|
121
|
+
stale = [e.id for e in target.entries.values()
|
|
122
|
+
if e.expires_after_tasks is not None
|
|
123
|
+
and target.tasks - e.made_at_task >= e.expires_after_tasks]
|
|
124
|
+
if stale:
|
|
125
|
+
event = self.apply([{"action": "delete", "id": eid} for eid in stale],
|
|
126
|
+
trigger="expire", summary="written for work that has moved on")
|
|
127
|
+
await self._record(event)
|
|
128
|
+
|
|
129
|
+
async def _auto(self, trigger: str) -> None:
|
|
130
|
+
self._last_check = time.monotonic()
|
|
131
|
+
kept = "\n".join(f"- {e.id}: {e.title}: {e.content}" for e in self.entries()) or "(empty)"
|
|
132
|
+
prompt = f"Current playbook:\n{kept}\n\nRecent conversation:\n{self._conversation()[-CHECK_CHARS:]}"
|
|
133
|
+
worth_it, focus = parse_check(await self.session._utility_completion(prompt, CHECK_SYSTEM))
|
|
134
|
+
if worth_it:
|
|
135
|
+
instructions = f"{AUTO_NOTE}\nFocus: {focus}" if focus else AUTO_NOTE
|
|
136
|
+
await self.improve(instructions, trigger=trigger)
|
|
137
|
+
|
|
138
|
+
async def improve(self, instructions: str | None, *, shared: bool = False,
|
|
139
|
+
trigger: str = "user") -> str:
|
|
140
|
+
if not self.writable:
|
|
141
|
+
return "Playbook is read-only."
|
|
142
|
+
scope = "global" if shared else "local"
|
|
143
|
+
target = self._store(scope)
|
|
144
|
+
baseline = {k: e.version for k, e in target.entries.items()}
|
|
145
|
+
request = build_request(self.entries(), target.history, self._conversation(),
|
|
146
|
+
scope=scope, instructions=instructions)
|
|
147
|
+
try:
|
|
148
|
+
proposal = parse_proposal(await self.session._utility_completion(request, SYSTEM))
|
|
149
|
+
except ValueError as exc:
|
|
150
|
+
return f"Playbook not changed: {exc}"
|
|
151
|
+
if trigger.startswith("auto:"):
|
|
152
|
+
for edit in proposal["edits"]:
|
|
153
|
+
if isinstance(edit, dict) and edit.get("kind") == "memory":
|
|
154
|
+
edit.setdefault("expires_after_tasks", AUTO_MEMORY_TASKS)
|
|
155
|
+
if self.hold_auto and proposal["edits"]:
|
|
156
|
+
self.held.append({"edits": proposal["edits"], "trigger": trigger, "scope": scope,
|
|
157
|
+
"summary": str(proposal.get("summary", "")),
|
|
158
|
+
"rationale": str(proposal.get("rationale", ""))})
|
|
159
|
+
return (f"Held proposal {len(self.held)} for review: "
|
|
160
|
+
f"{proposal.get('summary', '')} (/improve pending)")
|
|
161
|
+
event = self.apply(proposal["edits"], trigger=trigger, scope=scope, baseline=baseline,
|
|
162
|
+
summary=str(proposal.get("summary", "")),
|
|
163
|
+
rationale=str(proposal.get("rationale", "")))
|
|
164
|
+
await self._record(event)
|
|
165
|
+
return _describe(event)
|
|
166
|
+
|
|
167
|
+
async def _record(self, event: dict) -> None:
|
|
168
|
+
"""Log the change in the session, and in the shared log when it is shared."""
|
|
169
|
+
brief = {k: event[k] for k in ("id", "trigger", "scope", "summary", "time")}
|
|
170
|
+
brief["changes"] = [{k: c[k] for k in ("action", "id", "applied")} for c in event["changes"]]
|
|
171
|
+
note = getattr(self.session, "note_playbook_change", None)
|
|
172
|
+
if note is not None:
|
|
173
|
+
await note(brief)
|
|
174
|
+
shared = self.paths["global"]
|
|
175
|
+
if event["scope"] == "global" and shared is not None:
|
|
176
|
+
log = shared.with_name("playbook-changes.jsonl")
|
|
177
|
+
with log.open("a", encoding="utf-8") as f:
|
|
178
|
+
f.write(json.dumps(event, default=str) + "\n")
|
|
179
|
+
|
|
180
|
+
def apply(self, edits: list[dict], *, trigger: str, scope: str = "local",
|
|
181
|
+
baseline: dict | None = None, summary: str = "", rationale: str = "") -> dict:
|
|
182
|
+
if not self.writable:
|
|
183
|
+
raise RuntimeError("playbook is read-only")
|
|
184
|
+
target = self._store(scope)
|
|
185
|
+
if baseline is None:
|
|
186
|
+
baseline = {k: e.version for k, e in target.entries.items()}
|
|
187
|
+
event = apply_edits(target, edits, baseline=baseline, scope=scope, trigger=trigger,
|
|
188
|
+
summary=summary, rationale=rationale,
|
|
189
|
+
read_only=self.shared if scope == "local" else None)
|
|
190
|
+
save(self.paths[scope], target)
|
|
191
|
+
done = sorted({c["id"] for c in event["changes"] if c["applied"]})
|
|
192
|
+
evidence = getattr(self.session, "evidence", None)
|
|
193
|
+
if done and evidence is not None:
|
|
194
|
+
evidence.saved(event["id"], f"playbook:{trigger}", done, summary)
|
|
195
|
+
return event
|
|
196
|
+
|
|
197
|
+
async def undo(self, event_id: str) -> str:
|
|
198
|
+
for scope in ("local", "global"):
|
|
199
|
+
target = self._store(scope)
|
|
200
|
+
for event in target.history:
|
|
201
|
+
if event["id"] == event_id:
|
|
202
|
+
undone = self.apply(reverse_edits(event), trigger="undo", scope=scope,
|
|
203
|
+
summary=f"undo {event_id}")
|
|
204
|
+
await self._record(undone)
|
|
205
|
+
return f"Change {event_id} undone. " + _describe(undone)
|
|
206
|
+
return f"No playbook change with id {event_id}."
|
|
207
|
+
|
|
208
|
+
def show_digest(self) -> None:
|
|
209
|
+
"""Put the playbook in front of the model when it has changed since last shown."""
|
|
210
|
+
entries = self.entries()
|
|
211
|
+
fp = fingerprint(entries)
|
|
212
|
+
if fp == self._shown or (not entries and self._shown is None):
|
|
213
|
+
return
|
|
214
|
+
self._shown = fp
|
|
215
|
+
if not entries:
|
|
216
|
+
self.session.inject("[Playbook] Earlier entries no longer apply; it is now empty.")
|
|
217
|
+
return
|
|
218
|
+
transcript = self.session.transcript
|
|
219
|
+
goal = next((getattr(m, "text", "") for m in transcript if getattr(m, "role", "") == "user"), "")
|
|
220
|
+
recent = [getattr(m, "text", "") for m in transcript[-4:]]
|
|
221
|
+
self.session.inject(render(entries, goal=goal, recent=recent))
|
|
222
|
+
|
|
223
|
+
def _conversation(self) -> str:
|
|
224
|
+
return "\n".join(
|
|
225
|
+
f"[{getattr(m, 'role', '?')}] {getattr(m, 'text', '')}" for m in self.session.transcript
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def pending_text(self) -> str:
|
|
229
|
+
if not self.held:
|
|
230
|
+
return "No proposals are waiting."
|
|
231
|
+
rows = []
|
|
232
|
+
for n, held in enumerate(self.held, 1):
|
|
233
|
+
rows.append(f"{n}. [{held['trigger']}] {held['summary']}")
|
|
234
|
+
rows += [f" {e.get('action')} {e.get('kind', '')} {e.get('id', '')}: "
|
|
235
|
+
f"{str(e.get('content', e.get('title', '')))[:200]}" for e in held["edits"]]
|
|
236
|
+
return "\n".join(rows)
|
|
237
|
+
|
|
238
|
+
async def _decide(self, words: list[str]) -> str:
|
|
239
|
+
if len(words) != 2 or not words[1].isdigit() or not 1 <= int(words[1]) <= len(self.held):
|
|
240
|
+
return f"Use /improve {words[0]} N, with N from /improve pending."
|
|
241
|
+
held = self.held.pop(int(words[1]) - 1)
|
|
242
|
+
if words[0] == "reject":
|
|
243
|
+
return f"Dropped: {held['summary']}"
|
|
244
|
+
event = self.apply(held["edits"], trigger=held["trigger"], scope=held["scope"],
|
|
245
|
+
summary=held["summary"], rationale=held["rationale"])
|
|
246
|
+
await self._record(event)
|
|
247
|
+
self.show_digest()
|
|
248
|
+
return _describe(event)
|
|
249
|
+
|
|
250
|
+
async def command(self, arg: str) -> str:
|
|
251
|
+
words = arg.split()
|
|
252
|
+
if words[:1] == ["pending"]:
|
|
253
|
+
return self.pending_text()
|
|
254
|
+
if words[:1] in (["accept"], ["reject"]):
|
|
255
|
+
return await self._decide(words)
|
|
256
|
+
if words[:1] == ["list"]:
|
|
257
|
+
return "\n".join(f"{e.id} [{e.kind}, {e.scope}] {e.title}" for e in self.entries()) \
|
|
258
|
+
or "The playbook is empty."
|
|
259
|
+
if words[:1] == ["undo"] and len(words) == 2:
|
|
260
|
+
return await self.undo(words[1])
|
|
261
|
+
shared = "--shared" in words
|
|
262
|
+
instructions = " ".join(w for w in words if w != "--shared") or None
|
|
263
|
+
note = await self.improve(instructions, shared=shared, trigger="user")
|
|
264
|
+
self.show_digest()
|
|
265
|
+
return note
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _describe(event: dict) -> str:
|
|
269
|
+
done = [f"{c['action']} {c['id']}" for c in event["changes"] if c["applied"]]
|
|
270
|
+
skipped = [f"{c['id']} ({c['error']})" for c in event["changes"] if not c["applied"]]
|
|
271
|
+
text = f"[{event['id']}] " + (", ".join(done) if done else "no change")
|
|
272
|
+
return text + (f"; skipped: {', '.join(skipped)}" if skipped else "")
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Ask the model what to change in the playbook, and read its answer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from rlmagent.playbook.store import Entry
|
|
9
|
+
|
|
10
|
+
CONVERSATION_CHARS = 60_000
|
|
11
|
+
|
|
12
|
+
SYSTEM = """You maintain an agent's playbook: short entries the agent reads in later \
|
|
13
|
+
turns and later sessions. Read the conversation and decide whether anything in it \
|
|
14
|
+
is worth keeping. Most of the time the right answer is no change.
|
|
15
|
+
|
|
16
|
+
Entry kinds:
|
|
17
|
+
- rule: a narrow instruction for how to work ("run the tests before saying done").
|
|
18
|
+
- memory: a fact, decision, preference or outcome worth remembering.
|
|
19
|
+
- skill: a Python function the agent can reuse; `content` is its full source and \
|
|
20
|
+
`callable` the name of the function it defines.
|
|
21
|
+
- role: instructions for a kind of sub-agent the agent keeps starting.
|
|
22
|
+
|
|
23
|
+
Only keep what the conversation shows: a repeated mistake, a correction from the \
|
|
24
|
+
user, a procedure done more than once, a lasting fact. Prefer one small edit to \
|
|
25
|
+
many; prefer updating an entry to adding a near-duplicate; delete entries shown to \
|
|
26
|
+
be wrong. Never propose changes to source files.
|
|
27
|
+
|
|
28
|
+
Answer with one JSON object and nothing else:
|
|
29
|
+
{"summary": "one sentence", "rationale": "the evidence", "expected": "what should improve",
|
|
30
|
+
"edits": [{"action": "create|update|delete", "kind": "rule|memory|skill|role", "id": "short-id",
|
|
31
|
+
"title": "...", "content": "...", "callable": "only for skills",
|
|
32
|
+
"expires_after_tasks": "only for what stops being true once the current work moves \
|
|
33
|
+
on: how many finished tasks to keep it"}]}
|
|
34
|
+
An empty "edits" list is a good answer when nothing is worth keeping."""
|
|
35
|
+
|
|
36
|
+
SCOPES = {
|
|
37
|
+
"local": "These edits apply to this session only. Shared entries are shown for context "
|
|
38
|
+
"and are read-only: to change one for this session, create a local entry.",
|
|
39
|
+
"global": "These edits are shared by every future session. Only keep what will still be "
|
|
40
|
+
"true later: user preferences, reusable skills and roles, facts about tools "
|
|
41
|
+
"or about a named project.",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
CHECK_CHARS = 30_000
|
|
46
|
+
|
|
47
|
+
CHECK_SYSTEM = """You decide whether an agent's recent conversation holds anything worth \
|
|
48
|
+
keeping in its playbook: a correction from the user, a mistake made more than once, a \
|
|
49
|
+
procedure done twice, a lasting fact or preference. Routine work, one-off details and \
|
|
50
|
+
guesses are not worth keeping, and neither is anything the current playbook (shown \
|
|
51
|
+
first) already holds. Answer with one JSON object and nothing else:
|
|
52
|
+
{"worth_it": true or false, "why": "one sentence", "focus": "what to keep, if anything"}"""
|
|
53
|
+
|
|
54
|
+
AUTO_NOTE = (
|
|
55
|
+
"This update was started automatically. No change is a good answer. Keep only what "
|
|
56
|
+
"the conversation clearly shows. Nothing goes to the shared playbook unless the user "
|
|
57
|
+
"asked for it."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_check(text: str) -> tuple[bool, str]:
|
|
62
|
+
match = re.search(r"\{.*\}", text or "", re.S)
|
|
63
|
+
try:
|
|
64
|
+
answer = json.loads(match.group(0)) if match else {}
|
|
65
|
+
except json.JSONDecodeError:
|
|
66
|
+
answer = {}
|
|
67
|
+
return bool(answer.get("worth_it")), str(answer.get("focus") or "")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build_request(entries: list[Entry], history: list[dict], conversation: str, *,
|
|
71
|
+
scope: str, instructions: str | None) -> str:
|
|
72
|
+
if len(conversation) > CONVERSATION_CHARS:
|
|
73
|
+
conversation = "[earlier conversation omitted]\n" + conversation[-CONVERSATION_CHARS:]
|
|
74
|
+
state = "\n".join(
|
|
75
|
+
f"- [{e.kind}] {e.id} (v{e.version}, {e.scope}): {e.title}\n {e.content}" for e in entries
|
|
76
|
+
) or "(empty)"
|
|
77
|
+
past = "\n".join(f"- {h['id']}: {h.get('summary', '')}" for h in history[-10:]) or "(none)"
|
|
78
|
+
parts = [
|
|
79
|
+
f"<playbook>\n{state}\n</playbook>",
|
|
80
|
+
f"<earlier_changes>\n{past}\n</earlier_changes>",
|
|
81
|
+
f"<scope>\n{SCOPES[scope]}\n</scope>",
|
|
82
|
+
f"<conversation>\n{conversation}\n</conversation>",
|
|
83
|
+
]
|
|
84
|
+
if instructions:
|
|
85
|
+
parts.append(f"<instructions>\n{instructions}\n</instructions>")
|
|
86
|
+
return "\n\n".join(parts)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def parse_proposal(text: str) -> dict:
|
|
90
|
+
match = re.search(r"\{.*\}", text or "", re.S)
|
|
91
|
+
if not match:
|
|
92
|
+
raise ValueError("the planner's answer contained no JSON object")
|
|
93
|
+
try:
|
|
94
|
+
proposal = json.loads(match.group(0))
|
|
95
|
+
except json.JSONDecodeError as exc:
|
|
96
|
+
raise ValueError(f"the planner's answer is not valid JSON: {exc}") from None
|
|
97
|
+
if not isinstance(proposal.get("edits"), list):
|
|
98
|
+
raise ValueError("the planner's JSON has no `edits` list")
|
|
99
|
+
return proposal
|