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
@@ -0,0 +1,165 @@
1
+ """Reconstruct live conversation state from the session record tree."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from rlmagent.harness.contracts.transcript import (
8
+ PruneSummaryEntry,
9
+ TranscriptEntry,
10
+ )
11
+ from rlmagent.harness.session.records import (
12
+ ModelSwapRecord,
13
+ PruneRecord,
14
+ ReasoningLevelRecord,
15
+ SessionMetaRecord,
16
+ SessionRecord,
17
+ TagRecord,
18
+ TipRecord,
19
+ TranscriptRecord,
20
+ )
21
+
22
+
23
+ class TreeIntegrityError(ValueError):
24
+ """The record graph contains duplicates, cycles, or dangling references."""
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Lineage — walk the parent chain
29
+ # ---------------------------------------------------------------------------
30
+
31
+
32
+ def index_by_id(entries: list[SessionRecord]) -> dict[str, SessionRecord]:
33
+ """Map records by their id, raising on collisions."""
34
+ result: dict[str, SessionRecord] = {}
35
+ for entry in entries:
36
+ if entry.id in result:
37
+ raise TreeIntegrityError(f"Colliding record id: {entry.id}")
38
+ result[entry.id] = entry
39
+ return result
40
+
41
+
42
+ def trace_to_entry(entries: list[SessionRecord], leaf_id: str) -> list[SessionRecord]:
43
+ """Walk parent pointers from ``leaf_id`` to the root, returned root-first."""
44
+ by_id = index_by_id(entries)
45
+ path: list[SessionRecord] = []
46
+ seen: set[str] = set()
47
+ current_id: str | None = leaf_id
48
+
49
+ while current_id is not None:
50
+ if current_id in seen:
51
+ raise TreeIntegrityError(f"Parent-chain cycle at record: {current_id}")
52
+ seen.add(current_id)
53
+ entry = by_id.get(current_id)
54
+ if entry is None:
55
+ raise TreeIntegrityError(f"Dangling parent reference: {current_id}")
56
+ path.append(entry)
57
+ current_id = entry.parent_id
58
+
59
+ path.reverse()
60
+ return path
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Projection — fold records -> live state
65
+ # ---------------------------------------------------------------------------
66
+
67
+
68
+ @dataclass(frozen=True, slots=True)
69
+ class ProjectedState:
70
+ """Result of projecting an ordered record list into live session state."""
71
+
72
+ transcript: list[TranscriptEntry] = field(default_factory=list)
73
+ record_ids: list[str] = field(default_factory=list)
74
+ model: str | None = None
75
+ thinking_level: str | None = None
76
+ title: str | None = None
77
+ cwd: str | None = None
78
+
79
+
80
+ def _apply_prune(
81
+ rows: list[tuple[str, TranscriptEntry]],
82
+ record: PruneRecord,
83
+ ) -> list[tuple[str, TranscriptEntry]]:
84
+ """Replace the rows superseded by *record* with a single summary row."""
85
+ superseded = set(record.replaces_entry_ids)
86
+ summary = PruneSummaryEntry(summary=record.summary, tokens_before=0)
87
+
88
+ kept: list[tuple[str, TranscriptEntry]] = []
89
+ placed = False
90
+ for row_id, message in rows:
91
+ if row_id not in superseded:
92
+ kept.append((row_id, message))
93
+ continue
94
+ if not placed:
95
+ kept.append((record.id, summary))
96
+ placed = True
97
+
98
+ # A compaction that matched nothing still belongs in the transcript.
99
+ if not placed:
100
+ kept.append((record.id, summary))
101
+ return kept
102
+
103
+
104
+ def project_records(records: list[SessionRecord]) -> ProjectedState:
105
+ """Fold an ordered list of session records into live conversation state."""
106
+ rows: list[tuple[str, TranscriptEntry]] = []
107
+ model: str | None = None
108
+ thinking_level: str | None = None
109
+ title: str | None = None
110
+ cwd: str | None = None
111
+
112
+ for record in records:
113
+ if isinstance(record, TranscriptRecord):
114
+ rows.append((record.id, record.message))
115
+ elif isinstance(record, PruneRecord):
116
+ rows = _apply_prune(rows, record)
117
+ elif isinstance(record, ModelSwapRecord):
118
+ model = record.model
119
+ elif isinstance(record, ReasoningLevelRecord):
120
+ thinking_level = record.thinking_level
121
+ elif isinstance(record, TagRecord):
122
+ title = record.label
123
+ elif isinstance(record, SessionMetaRecord):
124
+ if record.title:
125
+ title = record.title
126
+ if record.cwd:
127
+ cwd = record.cwd
128
+
129
+ return ProjectedState(
130
+ transcript=[message for _row_id, message in rows],
131
+ record_ids=[row_id for row_id, _message in rows],
132
+ model=model,
133
+ thinking_level=thinking_level,
134
+ title=title,
135
+ cwd=cwd,
136
+ )
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Tip discovery
141
+ # ---------------------------------------------------------------------------
142
+
143
+
144
+ def find_tip(records: list[SessionRecord]) -> str | None:
145
+ """Return the entry_id from the most recent ``TipRecord``, or ``None``."""
146
+ for record in reversed(records):
147
+ if isinstance(record, TipRecord):
148
+ return record.entry_id
149
+ return None
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # Active-branch projection — the loader entry point
154
+ # ---------------------------------------------------------------------------
155
+
156
+
157
+ def project_active_branch(records: list[SessionRecord]) -> ProjectedState:
158
+ """Project only the active branch of the record tree."""
159
+ tip = find_tip(records)
160
+ if tip is None:
161
+ return project_records(records)
162
+ try:
163
+ return project_records(trace_to_entry(records, tip))
164
+ except TreeIntegrityError:
165
+ return project_records(records)
@@ -0,0 +1,155 @@
1
+ """Append-only JSONL persistence for session records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any, Protocol
8
+
9
+ from pydantic import TypeAdapter, ValidationError
10
+
11
+ from rlmagent.harness.session.records import FORMAT
12
+ from rlmagent.harness.session.records import SessionRecord
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Serialization + migration
16
+ # ---------------------------------------------------------------------------
17
+
18
+ _RECORD_ADAPTER: TypeAdapter[SessionRecord] = TypeAdapter(SessionRecord)
19
+
20
+
21
+ class RecordParseError(ValueError):
22
+ """A JSONL line could not be decoded into a valid session record."""
23
+
24
+
25
+ def jsonl_lines(text: str) -> list[str]:
26
+ r"""Split JSONL text into records on ``\n`` only."""
27
+ return [line.rstrip("\r") for line in text.split("\n")]
28
+
29
+
30
+ def serialize_record(entry: SessionRecord) -> str:
31
+ """Encode a single record to a JSONL line with no None fields."""
32
+ return _RECORD_ADAPTER.dump_json(entry, exclude_none=True).decode() + "\n"
33
+
34
+
35
+ def deserialize_record(line: str, *, line_number: int | None = None) -> SessionRecord:
36
+ """Decode a single JSONL line, upgrading legacy shapes on the fly."""
37
+ location = f" on line {line_number}" if line_number is not None else ""
38
+ try:
39
+ raw = json.loads(line)
40
+ upgraded = _upgrade_entry(raw)
41
+ if upgraded.get("type") == "session_info" and upgraded.get("format", 1) > FORMAT:
42
+ raise ValueError("this session was saved by a newer rlm-agent; upgrade to open it")
43
+ return _RECORD_ADAPTER.validate_python(upgraded)
44
+ except (json.JSONDecodeError, ValidationError, TypeError, ValueError) as exc:
45
+ raise RecordParseError(f"Unreadable session record{location}: {exc}") from exc
46
+
47
+
48
+ def deserialize_records(lines: list[str]) -> list[SessionRecord]:
49
+ """Decode all non-blank JSONL lines into session records."""
50
+ results: list[SessionRecord] = []
51
+ for index, line in enumerate(lines, start=1):
52
+ if not line.strip():
53
+ continue
54
+ results.append(deserialize_record(line, line_number=index))
55
+ return results
56
+
57
+
58
+ def _upgrade_entry(value: Any) -> Any:
59
+ """Normalize a raw dict into the current record schema."""
60
+ if not isinstance(value, dict) or value.get("type") != "message":
61
+ return value
62
+ migrated = dict(value)
63
+ migrated["message"] = _upgrade_message(value.get("message"))
64
+ return migrated
65
+
66
+
67
+ def _upgrade_message(value: Any) -> Any:
68
+ if not isinstance(value, dict):
69
+ return value
70
+ message = dict(value)
71
+ role = message.get("role")
72
+
73
+ if role == "user" and ("custom_type" in message or "customType" in message):
74
+ message["role"] = "custom"
75
+ message["customType"] = message.pop("custom_type", message.get("customType"))
76
+ message.pop("custom_type", None)
77
+ message.setdefault("display", True)
78
+ return message
79
+
80
+ if role == "assistant":
81
+ usage = message.get("usage")
82
+ if isinstance(usage, dict) and usage.get("cost") is None:
83
+ usage = dict(usage)
84
+ usage["cost"] = {}
85
+ message["usage"] = usage
86
+
87
+ content = message.get("content", "")
88
+ if isinstance(content, str):
89
+ blocks: list[Any] = []
90
+ if content:
91
+ blocks.append({"type": "text", "text": content})
92
+ blocks.extend(message.pop("tool_calls", message.pop("toolCalls", [])) or [])
93
+ message["content"] = blocks
94
+ elif "tool_calls" in message or "toolCalls" in message:
95
+ blocks = list(content or [])
96
+ blocks.extend(message.pop("tool_calls", message.pop("toolCalls", [])) or [])
97
+ message["content"] = blocks
98
+ return message
99
+
100
+ if role == "tool":
101
+ message["role"] = "toolResult"
102
+ message["toolName"] = message.pop("name", message.get("toolName", "unknown"))
103
+ message["toolCallId"] = message.pop("tool_call_id", message.get("toolCallId", ""))
104
+ message["isError"] = not bool(message.pop("ok", True))
105
+ content = message.get("content", "")
106
+ if isinstance(content, str):
107
+ message["content"] = [{"type": "text", "text": content}] if content else []
108
+ data = message.pop("data", None)
109
+ details = message.get("details")
110
+ if isinstance(data, dict) and isinstance(details, dict):
111
+ message["details"] = {**data, **details}
112
+ elif details is None and data is not None:
113
+ message["details"] = data
114
+ error = message.pop("error", None)
115
+ if error and not message["content"]:
116
+ message["content"] = [{"type": "text", "text": str(error)}]
117
+ return message
118
+
119
+ return message
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # Storage
124
+ # ---------------------------------------------------------------------------
125
+
126
+
127
+ class SessionVault(Protocol):
128
+ """Contract for any backend that persists session records."""
129
+
130
+ async def append(self, entry: SessionRecord) -> None:
131
+ """Write a single record to the end of the log."""
132
+ ...
133
+
134
+ async def read_all(self) -> list[SessionRecord]:
135
+ """Load every record in the order it was written."""
136
+ ...
137
+
138
+
139
+ class JsonlVault:
140
+ """File-backed vault that stores one JSON object per line."""
141
+
142
+ def __init__(self, path: str | Path) -> None:
143
+ self.path = Path(path)
144
+
145
+ async def append(self, entry: SessionRecord) -> None:
146
+ """Write a record, creating parent directories on first use."""
147
+ self.path.parent.mkdir(parents=True, exist_ok=True)
148
+ with self.path.open("a", encoding="utf-8") as file:
149
+ file.write(serialize_record(entry))
150
+
151
+ async def read_all(self) -> list[SessionRecord]:
152
+ """Load all records from disk. A missing file yields an empty list."""
153
+ if not self.path.exists():
154
+ return []
155
+ return deserialize_records(jsonl_lines(self.path.read_text(encoding="utf-8")))
@@ -0,0 +1,46 @@
1
+ """Aggregate a stored session into token and cost totals."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from rlmagent.harness.contracts.transcript import ModelEntry
8
+ from rlmagent.harness.session.records import SessionRecord, TranscriptRecord
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class RunSummary:
13
+ """Token and cost totals for a single session."""
14
+
15
+ session_id: str
16
+ model: str | None
17
+ provider: str | None
18
+ turns: int
19
+ input_tokens: int
20
+ output_tokens: int
21
+ cache_read_tokens: int
22
+ cache_write_tokens: int
23
+ total_tokens: int
24
+ cost_usd: float
25
+
26
+
27
+ def summarize_records(session_id: str, records: list[SessionRecord]) -> RunSummary:
28
+ """Sum the usage of every assistant turn in *records*."""
29
+ replies = [
30
+ record.message
31
+ for record in records
32
+ if isinstance(record, TranscriptRecord) and isinstance(record.message, ModelEntry)
33
+ ]
34
+
35
+ return RunSummary(
36
+ session_id=session_id,
37
+ model=replies[-1].model if replies else None,
38
+ provider=replies[-1].provider if replies else None,
39
+ turns=len(replies),
40
+ input_tokens=sum(reply.usage.input for reply in replies),
41
+ output_tokens=sum(reply.usage.output for reply in replies),
42
+ cache_read_tokens=sum(reply.usage.cache_read for reply in replies),
43
+ cache_write_tokens=sum(reply.usage.cache_write for reply in replies),
44
+ total_tokens=sum(reply.usage.total_tokens for reply in replies),
45
+ cost_usd=sum(reply.usage.cost.total for reply in replies),
46
+ )
rlmagent/hints.py ADDED
@@ -0,0 +1,47 @@
1
+ """Short notes added to a cell's output when it failed in a way models often repeat."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ _RULES: list[tuple[re.Pattern, str]] = [
8
+ (re.compile(r"f-string: (expecting|unmatched|single '\}')|f-string: .*quote", re.I),
9
+ "Inside an f-string, use the other kind of quote for strings in the braces, or build "
10
+ "the value in a variable first."),
11
+ (re.compile(r"unterminated (triple-quoted )?string literal", re.I),
12
+ "A string was not closed. For text with quotes or many lines, use triple quotes or "
13
+ "write it to a variable in pieces."),
14
+ (re.compile(r"coroutine .* was never awaited|<coroutine object", re.I),
15
+ "A host function was called without await. Write `await read_file(...)`, not "
16
+ "`read_file(...)`."),
17
+ (re.compile(r"object (dict|list|str|NoneType) can't be used in 'await' expression", re.I),
18
+ "That function is not a coroutine; call it without await."),
19
+ (re.compile(r"'await' outside (async )?function", re.I),
20
+ "Top-level await works in a cell, but not inside a plain def; make it `async def` and "
21
+ "await the call."),
22
+ (re.compile(r"ModuleNotFoundError: No module named '([\w.]+)'"),
23
+ "The module is not installed in this kernel. In the sandbox, try "
24
+ "`import micropip; await micropip.install(...)` when the network is on, or do the work "
25
+ "with run() on the host."),
26
+ (re.compile(r"no response within \d+s|timed out after", re.I),
27
+ "The cell ran past its time limit. Start long commands with job_start(command) and "
28
+ "read them with job_read, or split the work into smaller cells."),
29
+ (re.compile(r"NameError: name 'FINAL' is not defined"),
30
+ "FINAL is only defined at the top level of the kernel; call it outside functions."),
31
+ ]
32
+
33
+ EMPTY_CELL = ("The reply had no code to run. Answer with a Python cell; call FINAL(answer) "
34
+ "when the task is done.")
35
+
36
+
37
+ def hint_for(code: str | None, output: str) -> str | None:
38
+ """One hint for what went wrong, or None when nothing familiar shows."""
39
+ if code is not None and not code.strip():
40
+ return EMPTY_CELL
41
+ for pattern, hint in _RULES:
42
+ if pattern.search(output):
43
+ return hint
44
+ return None
45
+
46
+
47
+ __all__ = ["EMPTY_CELL", "hint_for"]
rlmagent/history.py ADDED
@@ -0,0 +1,92 @@
1
+ """The whole record of a session, readable from a cell after compaction has shortened it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ MAX_ITEMS = 200
9
+ MAX_TEXT = 20_000
10
+
11
+
12
+ def _text(content) -> str:
13
+ if isinstance(content, str):
14
+ return content
15
+ parts = []
16
+ for part in content or []:
17
+ if not isinstance(part, dict):
18
+ continue
19
+ kind = part.get("type")
20
+ if kind == "text":
21
+ parts.append(part.get("text", ""))
22
+ elif kind == "toolCall":
23
+ parts.append(f"[calls {part.get('name')}: {json.dumps(part.get('arguments'))[:2000]}]")
24
+ elif kind == "image":
25
+ parts.append("[image]")
26
+ return "\n".join(parts)
27
+
28
+
29
+ def _rows(path: Path) -> list[dict]:
30
+ rows = []
31
+ if not path.exists():
32
+ return rows
33
+ for line in path.read_text(encoding="utf-8").splitlines():
34
+ try:
35
+ row = json.loads(line)
36
+ except json.JSONDecodeError:
37
+ continue
38
+ if isinstance(row, dict):
39
+ rows.append(row)
40
+ return rows
41
+
42
+
43
+ def conversation(vault: Path) -> list[dict]:
44
+ """Every message the session saved, in order, including ones compaction summarized away."""
45
+ found = []
46
+ for row in _rows(vault):
47
+ if row.get("type") == "message":
48
+ message = row.get("message") or {}
49
+ found.append({"role": message.get("role", "?"),
50
+ "text": _text(message.get("content"))[:MAX_TEXT],
51
+ "time": row.get("timestamp")})
52
+ elif row.get("type") == "compaction":
53
+ found.append({"role": "compaction", "text": str(row.get("summary", ""))[:MAX_TEXT],
54
+ "time": row.get("timestamp")})
55
+ return found
56
+
57
+
58
+ def agent_steps(events: Path, agent: str) -> list[dict]:
59
+ """One agent's steps from the run log: its code and what the code printed."""
60
+ steps: dict[int, dict] = {}
61
+ for row in _rows(events):
62
+ if row.get("run_id") != agent or not isinstance(row.get("step"), int):
63
+ continue
64
+ step = steps.setdefault(row["step"], {"step": row["step"], "code": "", "output": ""})
65
+ if row.get("kind") == "code_generated":
66
+ step["code"] = str(row.get("code") or "")[:MAX_TEXT]
67
+ elif row.get("kind") == "output_received":
68
+ step["output"] = str(row.get("output") or "")[:MAX_TEXT]
69
+ return [steps[n] for n in sorted(steps)]
70
+
71
+
72
+ def make_history(vault_path, events_path):
73
+ """history() for agents; the two callables give the current files, or None."""
74
+
75
+ async def history(start: int = 0, end: int | None = None, agent: str | None = None) -> dict:
76
+ """The full record, with nothing lost to compaction: messages[start:end] of this
77
+ session, or with agent=RUN_ID that agent's steps (code and output). At most 200 items
78
+ per call; "total" tells you how many there are."""
79
+ if agent is None:
80
+ path = vault_path()
81
+ items = conversation(Path(path)) if path else []
82
+ else:
83
+ path = events_path()
84
+ items = agent_steps(Path(path), str(agent)) if path else []
85
+ stop = len(items) if end is None else int(end)
86
+ picked = items[int(start):stop][:MAX_ITEMS]
87
+ return {"total": len(items), "start": int(start), "items": picked}
88
+
89
+ return history
90
+
91
+
92
+ __all__ = ["agent_steps", "conversation", "make_history"]
@@ -0,0 +1 @@
1
+ """rlm-agent hooks: lifecycle event hooks (before/after agent, before/after tool, on error). NEW."""
@@ -0,0 +1 @@
1
+ """Observability / verification hook seam (callbacks on the event stream). TODO."""
@@ -0,0 +1,157 @@
1
+ """System-prompt assembly."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Sequence
7
+ from dataclasses import dataclass
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+
11
+ from rlmagent.skillset import Skill, build_skill_index
12
+ from rlmagent.harness.contracts.tooling import ToolSpec
13
+
14
+ _AGENTS_FILE = "AGENTS.md"
15
+
16
+ _BASE_IDENTITY = """You are rlm-agent, a coding agent working in the user's project. Your main tool is a persistent Python kernel; your other tools read, write and edit files.
17
+
18
+ Work carefully: read a file before editing it, make minimal focused changes, and verify your work by running a test or a command in the kernel when one is available. Prefer clear, direct answers over hedging."""
19
+
20
+
21
+ def _tool_guidelines(tools: Sequence[ToolSpec]) -> str:
22
+ """Render each tool's ``prompt_snippet``/``prompt_guidelines``, skipping tools with neither."""
23
+ seen: set[str] = set()
24
+ sections: list[str] = []
25
+ for tool in tools:
26
+ parts: list[str] = []
27
+ if tool.prompt_snippet:
28
+ parts.append(tool.prompt_snippet)
29
+ for guideline in tool.prompt_guidelines:
30
+ text = guideline.strip()
31
+ if text and text not in seen:
32
+ seen.add(text)
33
+ parts.append(f"- {text}")
34
+ if parts:
35
+ sections.append(f"### {tool.name}\n" + "\n".join(parts))
36
+ if not sections:
37
+ return ""
38
+ return "## Tool guidelines\n\n" + "\n\n".join(sections)
39
+
40
+
41
+ def _skill_section(skills: Sequence[Skill]) -> str:
42
+ return build_skill_index(list(skills))
43
+
44
+
45
+ def _agents_md_search_paths(cwd: str) -> list[Path]:
46
+ """Return ``AGENTS.md`` search paths in lowest-to-highest precedence order."""
47
+ from rlmagent.discovery import default_paths
48
+
49
+ paths = default_paths(project=Path(cwd))
50
+ user = [
51
+ paths.home / _AGENTS_FILE, # ~/.rlm-agent/AGENTS.md
52
+ paths.agents_home / _AGENTS_FILE, # ~/.agents/AGENTS.md
53
+ ]
54
+ if not paths.project_enabled:
55
+ return user
56
+ return [
57
+ *user,
58
+ paths.project / ".rlm-agent" / _AGENTS_FILE, # <project>/.rlm-agent/AGENTS.md
59
+ paths.project / ".agents" / _AGENTS_FILE, # <project>/.agents/AGENTS.md
60
+ paths.project / _AGENTS_FILE, # <project>/AGENTS.md (highest)
61
+ ]
62
+
63
+
64
+ def _read_agents_file(path: Path) -> str:
65
+ """Read and return the trimmed contents of an AGENTS.md, or empty string."""
66
+ if not path.is_file():
67
+ return ""
68
+ try:
69
+ return path.read_text(encoding="utf-8").strip()
70
+ except OSError:
71
+ return ""
72
+
73
+
74
+ def _project_sections(cwd: str) -> list[PromptSection]:
75
+ """One section per ``AGENTS.md`` found, in precedence order."""
76
+ found = [
77
+ (path, content)
78
+ for path in _agents_md_search_paths(cwd)
79
+ if (content := _read_agents_file(path))
80
+ ]
81
+ sections: list[PromptSection] = []
82
+ for index, (path, content) in enumerate(found):
83
+ text = content
84
+ if index == 0:
85
+ text = f"## Project instructions ({_AGENTS_FILE})\n\n{content}"
86
+ sections.append(PromptSection("Project instructions", str(path), text))
87
+ return sections
88
+
89
+
90
+ def _project_context(cwd: str) -> str:
91
+ """Collect project instructions from ``AGENTS.md`` across the precedence hierarchy."""
92
+ return "\n\n".join(section.text for section in _project_sections(cwd))
93
+
94
+
95
+ def _environment_suffix(cwd: str, *, include_date: bool) -> str:
96
+ lines = [f"Working directory: {cwd}"]
97
+ if include_date:
98
+ lines.append(f"Current date: {datetime.now(UTC):%Y-%m-%d}")
99
+ return "\n".join(lines)
100
+
101
+
102
+ @dataclass(frozen=True, slots=True)
103
+ class PromptSection:
104
+ """One contiguous piece of the system prompt and where it came from."""
105
+
106
+ title: str
107
+ origin: str
108
+ text: str
109
+
110
+
111
+ def prompt_sections(
112
+ *,
113
+ tools: Sequence[ToolSpec] = (),
114
+ skills: Sequence[Skill] = (),
115
+ cwd: str | None = None,
116
+ include_date: bool = True,
117
+ identity: str | None = None,
118
+ project: bool = True,
119
+ ) -> list[PromptSection]:
120
+ """The system prompt as attributed sections, in the order they are sent."""
121
+ resolved_cwd = cwd or os.getcwd()
122
+ tool_text = _tool_guidelines(tools)
123
+ skill_text = _skill_section(skills)
124
+ sections = [PromptSection("Identity", "built-in", identity or _BASE_IDENTITY)]
125
+ if tool_text:
126
+ names = ", ".join(t.name for t in tools if t.prompt_snippet or t.prompt_guidelines)
127
+ sections.append(PromptSection("Tool guidelines", f"tools: {names}", tool_text))
128
+ if skill_text:
129
+ sections.append(PromptSection("Skills", f"{len(skills)} loaded skill(s)", skill_text))
130
+ if project:
131
+ sections += _project_sections(resolved_cwd)
132
+ sections.append(PromptSection(
133
+ "Environment",
134
+ "working directory" + (" and date" if include_date else ""),
135
+ _environment_suffix(resolved_cwd, include_date=include_date),
136
+ ))
137
+ return sections
138
+
139
+
140
+ def system_prompt(
141
+ *,
142
+ tools: Sequence[ToolSpec] = (),
143
+ skills: Sequence[Skill] = (),
144
+ cwd: str | None = None,
145
+ include_date: bool = True,
146
+ identity: str | None = None,
147
+ project: bool = True,
148
+ ) -> str:
149
+ """Assemble the full system prompt: identity, tool guidance, skills, project context, env."""
150
+ sections = prompt_sections(
151
+ tools=tools, skills=skills, cwd=cwd, include_date=include_date,
152
+ identity=identity, project=project,
153
+ )
154
+ return "\n\n".join(section.text for section in sections)
155
+
156
+
157
+ __all__ = ["PromptSection", "prompt_sections", "system_prompt"]