rlmness-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. rlmagent/__init__.py +1 -0
  2. rlmagent/__main__.py +8 -0
  3. rlmagent/acp/__init__.py +1 -0
  4. rlmagent/acp/runtime.py +71 -0
  5. rlmagent/acp/server.py +598 -0
  6. rlmagent/acp/updates.py +128 -0
  7. rlmagent/agentnet.py +237 -0
  8. rlmagent/agents/__init__.py +1 -0
  9. rlmagent/agents/approval.py +90 -0
  10. rlmagent/agents/bridge.py +109 -0
  11. rlmagent/agents/budget.py +212 -0
  12. rlmagent/agents/child_handles.py +46 -0
  13. rlmagent/agents/child_mailbox.py +30 -0
  14. rlmagent/agents/kernel_api.py +156 -0
  15. rlmagent/agents/output.py +34 -0
  16. rlmagent/agents/patch.py +107 -0
  17. rlmagent/agents/prompts.py +156 -0
  18. rlmagent/agents/trace.py +290 -0
  19. rlmagent/agents/tree.py +682 -0
  20. rlmagent/agents/wasm_api.py +105 -0
  21. rlmagent/agents/workspace.py +357 -0
  22. rlmagent/autopilot.py +447 -0
  23. rlmagent/backend.py +946 -0
  24. rlmagent/backend_context.py +74 -0
  25. rlmagent/cli/__init__.py +5 -0
  26. rlmagent/cli/config_cmd.py +177 -0
  27. rlmagent/cli/health.py +156 -0
  28. rlmagent/cli/main.py +1194 -0
  29. rlmagent/cli/update.py +188 -0
  30. rlmagent/clipboard.py +63 -0
  31. rlmagent/config/__init__.py +37 -0
  32. rlmagent/config/config.py +297 -0
  33. rlmagent/config/loader.py +119 -0
  34. rlmagent/config/prompts.py +124 -0
  35. rlmagent/config/secure_input.py +213 -0
  36. rlmagent/config/store.py +275 -0
  37. rlmagent/context/__init__.py +47 -0
  38. rlmagent/context/budget.py +557 -0
  39. rlmagent/contract.py +311 -0
  40. rlmagent/contract_run.py +232 -0
  41. rlmagent/conversation.py +2547 -0
  42. rlmagent/cron.py +77 -0
  43. rlmagent/directives.py +893 -0
  44. rlmagent/discovery.py +356 -0
  45. rlmagent/execution_log.py +66 -0
  46. rlmagent/factory.py +189 -0
  47. rlmagent/factory_graph.py +413 -0
  48. rlmagent/harness/__init__.py +63 -0
  49. rlmagent/harness/contracts/__init__.py +1 -0
  50. rlmagent/harness/contracts/stream.py +129 -0
  51. rlmagent/harness/contracts/tooling.py +127 -0
  52. rlmagent/harness/contracts/transcript/__init__.py +68 -0
  53. rlmagent/harness/contracts/transcript/_base.py +29 -0
  54. rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
  55. rlmagent/harness/contracts/transcript/entries.py +180 -0
  56. rlmagent/harness/contracts/transcript/segments.py +64 -0
  57. rlmagent/harness/contracts/values.py +6 -0
  58. rlmagent/harness/driver.py +397 -0
  59. rlmagent/harness/engine.py +489 -0
  60. rlmagent/harness/mending.py +153 -0
  61. rlmagent/harness/provider/__init__.py +1 -0
  62. rlmagent/harness/provider/base.py +27 -0
  63. rlmagent/harness/provider/wire.py +189 -0
  64. rlmagent/harness/py.typed +0 -0
  65. rlmagent/harness/session/__init__.py +5 -0
  66. rlmagent/harness/session/index.py +218 -0
  67. rlmagent/harness/session/lease.py +66 -0
  68. rlmagent/harness/session/records.py +118 -0
  69. rlmagent/harness/session/replay.py +165 -0
  70. rlmagent/harness/session/store.py +155 -0
  71. rlmagent/harness/session/summary.py +46 -0
  72. rlmagent/hints.py +47 -0
  73. rlmagent/history.py +92 -0
  74. rlmagent/hooks/__init__.py +1 -0
  75. rlmagent/hooks/hooks.py +1 -0
  76. rlmagent/instructions.py +157 -0
  77. rlmagent/job_runner.py +117 -0
  78. rlmagent/jobs.py +365 -0
  79. rlmagent/kept_agents.py +397 -0
  80. rlmagent/kernel/__init__.py +5 -0
  81. rlmagent/kernel/session.py +205 -0
  82. rlmagent/kernel/wasm.py +169 -0
  83. rlmagent/logs.py +208 -0
  84. rlmagent/managed_release.py +368 -0
  85. rlmagent/mcp.py +411 -0
  86. rlmagent/mcp_oauth.py +213 -0
  87. rlmagent/model/__init__.py +29 -0
  88. rlmagent/model/_claude/__init__.py +1 -0
  89. rlmagent/model/_claude/courier.py +117 -0
  90. rlmagent/model/_claude/emitter.py +378 -0
  91. rlmagent/model/_oai/__init__.py +1 -0
  92. rlmagent/model/_oai/helpers.py +256 -0
  93. rlmagent/model/_oai/normalize.py +263 -0
  94. rlmagent/model/_oai/parsers.py +317 -0
  95. rlmagent/model/_oai/payloads.py +226 -0
  96. rlmagent/model/_oai/transport.py +127 -0
  97. rlmagent/model/claude.py +552 -0
  98. rlmagent/model/copilot.py +142 -0
  99. rlmagent/model/correlation.py +21 -0
  100. rlmagent/model/deadline.py +19 -0
  101. rlmagent/model/discovery.py +181 -0
  102. rlmagent/model/external/__init__.py +11 -0
  103. rlmagent/model/external/acp.py +202 -0
  104. rlmagent/model/external/acp_install.py +109 -0
  105. rlmagent/model/external/cli.py +248 -0
  106. rlmagent/model/external/textcalls.py +57 -0
  107. rlmagent/model/failover.py +129 -0
  108. rlmagent/model/google.py +199 -0
  109. rlmagent/model/limits.py +46 -0
  110. rlmagent/model/oai_compatible.py +414 -0
  111. rlmagent/model/preferences.py +56 -0
  112. rlmagent/model/py.typed +0 -0
  113. rlmagent/model/registered_oauth.py +195 -0
  114. rlmagent/model/scripted.py +55 -0
  115. rlmagent/model/settings.py +271 -0
  116. rlmagent/model/subscription.py +398 -0
  117. rlmagent/model/token_bounds.py +15 -0
  118. rlmagent/model/transport/__init__.py +1 -0
  119. rlmagent/model/transport/backoff.py +87 -0
  120. rlmagent/model/transport/client.py +87 -0
  121. rlmagent/model/transport/faults.py +65 -0
  122. rlmagent/model/transport/responses_socket.py +223 -0
  123. rlmagent/model/vertex.py +93 -0
  124. rlmagent/network.py +123 -0
  125. rlmagent/packages.py +246 -0
  126. rlmagent/planning.py +84 -0
  127. rlmagent/playbook/__init__.py +1 -0
  128. rlmagent/playbook/apply.py +106 -0
  129. rlmagent/playbook/digest.py +63 -0
  130. rlmagent/playbook/improver.py +272 -0
  131. rlmagent/playbook/plan.py +99 -0
  132. rlmagent/playbook/store.py +67 -0
  133. rlmagent/plugins.py +327 -0
  134. rlmagent/policy.py +105 -0
  135. rlmagent/prompt_refs.py +57 -0
  136. rlmagent/prompts.py +331 -0
  137. rlmagent/prune.py +60 -0
  138. rlmagent/py.typed +0 -0
  139. rlmagent/reasoning.py +135 -0
  140. rlmagent/refinement/__init__.py +1 -0
  141. rlmagent/refinement/controller.py +448 -0
  142. rlmagent/refinement/engine.py +220 -0
  143. rlmagent/refinement/evidence.py +131 -0
  144. rlmagent/refinement/scoped.py +90 -0
  145. rlmagent/refinement/store.py +193 -0
  146. rlmagent/refresh.py +202 -0
  147. rlmagent/routing.py +52 -0
  148. rlmagent/runtime.py +575 -0
  149. rlmagent/safety.py +267 -0
  150. rlmagent/session_html.py +57 -0
  151. rlmagent/session_import.py +52 -0
  152. rlmagent/session_share.py +55 -0
  153. rlmagent/sessions.py +249 -0
  154. rlmagent/shell_sandbox.py +88 -0
  155. rlmagent/skillset.py +320 -0
  156. rlmagent/steering.py +148 -0
  157. rlmagent/stops.py +62 -0
  158. rlmagent/subscriptions.py +219 -0
  159. rlmagent/tasks.py +98 -0
  160. rlmagent/toolhost.py +310 -0
  161. rlmagent/tools/__init__.py +56 -0
  162. rlmagent/tools/_shared.py +210 -0
  163. rlmagent/tools/files.py +462 -0
  164. rlmagent/tools/kernel.py +93 -0
  165. rlmagent/tools/patch_tool.py +68 -0
  166. rlmagent/tools/search.py +188 -0
  167. rlmagent/trust.py +383 -0
  168. rlmagent/tui/__init__.py +1 -0
  169. rlmagent/tui/adapter.py +506 -0
  170. rlmagent/tui/app.py +711 -0
  171. rlmagent/tui/approval_screen.py +57 -0
  172. rlmagent/tui/choice_screen.py +47 -0
  173. rlmagent/tui/completions.py +116 -0
  174. rlmagent/tui/components.py +25 -0
  175. rlmagent/tui/composer.py +40 -0
  176. rlmagent/tui/factory_view.py +86 -0
  177. rlmagent/tui/inspect.py +193 -0
  178. rlmagent/tui/messages.py +215 -0
  179. rlmagent/tui/model_screen.py +307 -0
  180. rlmagent/tui/prompt.py +98 -0
  181. rlmagent/tui/question_screen.py +52 -0
  182. rlmagent/tui/replay.py +141 -0
  183. rlmagent/tui/rlm_agent.tcss +405 -0
  184. rlmagent/tui/search_box.py +91 -0
  185. rlmagent/tui/sidebar.py +182 -0
  186. rlmagent/tui/status.py +49 -0
  187. rlmagent/tui/steps.py +283 -0
  188. rlmagent/tui/suggest.py +778 -0
  189. rlmagent/tui/transcript.py +172 -0
  190. rlmagent/tui/trust_dialog.py +58 -0
  191. rlmagent/tui/worker_attach.py +141 -0
  192. rlmagent/ui/__init__.py +1 -0
  193. rlmagent/ui/render.py +211 -0
  194. rlmagent/undo.py +109 -0
  195. rlmagent/utils/__init__.py +1 -0
  196. rlmagent/websearch.py +75 -0
  197. rlmagent/worker.py +1275 -0
  198. rlmagent/worker_service.py +203 -0
  199. rlmness_cli-0.2.0.dist-info/METADATA +882 -0
  200. rlmness_cli-0.2.0.dist-info/RECORD +203 -0
  201. rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
  202. rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
  203. rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
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