rlmness-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rlmagent/__init__.py +1 -0
- rlmagent/__main__.py +8 -0
- rlmagent/acp/__init__.py +1 -0
- rlmagent/acp/runtime.py +71 -0
- rlmagent/acp/server.py +598 -0
- rlmagent/acp/updates.py +128 -0
- rlmagent/agentnet.py +237 -0
- rlmagent/agents/__init__.py +1 -0
- rlmagent/agents/approval.py +90 -0
- rlmagent/agents/bridge.py +109 -0
- rlmagent/agents/budget.py +212 -0
- rlmagent/agents/child_handles.py +46 -0
- rlmagent/agents/child_mailbox.py +30 -0
- rlmagent/agents/kernel_api.py +156 -0
- rlmagent/agents/output.py +34 -0
- rlmagent/agents/patch.py +107 -0
- rlmagent/agents/prompts.py +156 -0
- rlmagent/agents/trace.py +290 -0
- rlmagent/agents/tree.py +682 -0
- rlmagent/agents/wasm_api.py +105 -0
- rlmagent/agents/workspace.py +357 -0
- rlmagent/autopilot.py +447 -0
- rlmagent/backend.py +946 -0
- rlmagent/backend_context.py +74 -0
- rlmagent/cli/__init__.py +5 -0
- rlmagent/cli/config_cmd.py +177 -0
- rlmagent/cli/health.py +156 -0
- rlmagent/cli/main.py +1194 -0
- rlmagent/cli/update.py +188 -0
- rlmagent/clipboard.py +63 -0
- rlmagent/config/__init__.py +37 -0
- rlmagent/config/config.py +297 -0
- rlmagent/config/loader.py +119 -0
- rlmagent/config/prompts.py +124 -0
- rlmagent/config/secure_input.py +213 -0
- rlmagent/config/store.py +275 -0
- rlmagent/context/__init__.py +47 -0
- rlmagent/context/budget.py +557 -0
- rlmagent/contract.py +311 -0
- rlmagent/contract_run.py +232 -0
- rlmagent/conversation.py +2547 -0
- rlmagent/cron.py +77 -0
- rlmagent/directives.py +893 -0
- rlmagent/discovery.py +356 -0
- rlmagent/execution_log.py +66 -0
- rlmagent/factory.py +189 -0
- rlmagent/factory_graph.py +413 -0
- rlmagent/harness/__init__.py +63 -0
- rlmagent/harness/contracts/__init__.py +1 -0
- rlmagent/harness/contracts/stream.py +129 -0
- rlmagent/harness/contracts/tooling.py +127 -0
- rlmagent/harness/contracts/transcript/__init__.py +68 -0
- rlmagent/harness/contracts/transcript/_base.py +29 -0
- rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
- rlmagent/harness/contracts/transcript/entries.py +180 -0
- rlmagent/harness/contracts/transcript/segments.py +64 -0
- rlmagent/harness/contracts/values.py +6 -0
- rlmagent/harness/driver.py +397 -0
- rlmagent/harness/engine.py +489 -0
- rlmagent/harness/mending.py +153 -0
- rlmagent/harness/provider/__init__.py +1 -0
- rlmagent/harness/provider/base.py +27 -0
- rlmagent/harness/provider/wire.py +189 -0
- rlmagent/harness/py.typed +0 -0
- rlmagent/harness/session/__init__.py +5 -0
- rlmagent/harness/session/index.py +218 -0
- rlmagent/harness/session/lease.py +66 -0
- rlmagent/harness/session/records.py +118 -0
- rlmagent/harness/session/replay.py +165 -0
- rlmagent/harness/session/store.py +155 -0
- rlmagent/harness/session/summary.py +46 -0
- rlmagent/hints.py +47 -0
- rlmagent/history.py +92 -0
- rlmagent/hooks/__init__.py +1 -0
- rlmagent/hooks/hooks.py +1 -0
- rlmagent/instructions.py +157 -0
- rlmagent/job_runner.py +117 -0
- rlmagent/jobs.py +365 -0
- rlmagent/kept_agents.py +397 -0
- rlmagent/kernel/__init__.py +5 -0
- rlmagent/kernel/session.py +205 -0
- rlmagent/kernel/wasm.py +169 -0
- rlmagent/logs.py +208 -0
- rlmagent/managed_release.py +368 -0
- rlmagent/mcp.py +411 -0
- rlmagent/mcp_oauth.py +213 -0
- rlmagent/model/__init__.py +29 -0
- rlmagent/model/_claude/__init__.py +1 -0
- rlmagent/model/_claude/courier.py +117 -0
- rlmagent/model/_claude/emitter.py +378 -0
- rlmagent/model/_oai/__init__.py +1 -0
- rlmagent/model/_oai/helpers.py +256 -0
- rlmagent/model/_oai/normalize.py +263 -0
- rlmagent/model/_oai/parsers.py +317 -0
- rlmagent/model/_oai/payloads.py +226 -0
- rlmagent/model/_oai/transport.py +127 -0
- rlmagent/model/claude.py +552 -0
- rlmagent/model/copilot.py +142 -0
- rlmagent/model/correlation.py +21 -0
- rlmagent/model/deadline.py +19 -0
- rlmagent/model/discovery.py +181 -0
- rlmagent/model/external/__init__.py +11 -0
- rlmagent/model/external/acp.py +202 -0
- rlmagent/model/external/acp_install.py +109 -0
- rlmagent/model/external/cli.py +248 -0
- rlmagent/model/external/textcalls.py +57 -0
- rlmagent/model/failover.py +129 -0
- rlmagent/model/google.py +199 -0
- rlmagent/model/limits.py +46 -0
- rlmagent/model/oai_compatible.py +414 -0
- rlmagent/model/preferences.py +56 -0
- rlmagent/model/py.typed +0 -0
- rlmagent/model/registered_oauth.py +195 -0
- rlmagent/model/scripted.py +55 -0
- rlmagent/model/settings.py +271 -0
- rlmagent/model/subscription.py +398 -0
- rlmagent/model/token_bounds.py +15 -0
- rlmagent/model/transport/__init__.py +1 -0
- rlmagent/model/transport/backoff.py +87 -0
- rlmagent/model/transport/client.py +87 -0
- rlmagent/model/transport/faults.py +65 -0
- rlmagent/model/transport/responses_socket.py +223 -0
- rlmagent/model/vertex.py +93 -0
- rlmagent/network.py +123 -0
- rlmagent/packages.py +246 -0
- rlmagent/planning.py +84 -0
- rlmagent/playbook/__init__.py +1 -0
- rlmagent/playbook/apply.py +106 -0
- rlmagent/playbook/digest.py +63 -0
- rlmagent/playbook/improver.py +272 -0
- rlmagent/playbook/plan.py +99 -0
- rlmagent/playbook/store.py +67 -0
- rlmagent/plugins.py +327 -0
- rlmagent/policy.py +105 -0
- rlmagent/prompt_refs.py +57 -0
- rlmagent/prompts.py +331 -0
- rlmagent/prune.py +60 -0
- rlmagent/py.typed +0 -0
- rlmagent/reasoning.py +135 -0
- rlmagent/refinement/__init__.py +1 -0
- rlmagent/refinement/controller.py +448 -0
- rlmagent/refinement/engine.py +220 -0
- rlmagent/refinement/evidence.py +131 -0
- rlmagent/refinement/scoped.py +90 -0
- rlmagent/refinement/store.py +193 -0
- rlmagent/refresh.py +202 -0
- rlmagent/routing.py +52 -0
- rlmagent/runtime.py +575 -0
- rlmagent/safety.py +267 -0
- rlmagent/session_html.py +57 -0
- rlmagent/session_import.py +52 -0
- rlmagent/session_share.py +55 -0
- rlmagent/sessions.py +249 -0
- rlmagent/shell_sandbox.py +88 -0
- rlmagent/skillset.py +320 -0
- rlmagent/steering.py +148 -0
- rlmagent/stops.py +62 -0
- rlmagent/subscriptions.py +219 -0
- rlmagent/tasks.py +98 -0
- rlmagent/toolhost.py +310 -0
- rlmagent/tools/__init__.py +56 -0
- rlmagent/tools/_shared.py +210 -0
- rlmagent/tools/files.py +462 -0
- rlmagent/tools/kernel.py +93 -0
- rlmagent/tools/patch_tool.py +68 -0
- rlmagent/tools/search.py +188 -0
- rlmagent/trust.py +383 -0
- rlmagent/tui/__init__.py +1 -0
- rlmagent/tui/adapter.py +506 -0
- rlmagent/tui/app.py +711 -0
- rlmagent/tui/approval_screen.py +57 -0
- rlmagent/tui/choice_screen.py +47 -0
- rlmagent/tui/completions.py +116 -0
- rlmagent/tui/components.py +25 -0
- rlmagent/tui/composer.py +40 -0
- rlmagent/tui/factory_view.py +86 -0
- rlmagent/tui/inspect.py +193 -0
- rlmagent/tui/messages.py +215 -0
- rlmagent/tui/model_screen.py +307 -0
- rlmagent/tui/prompt.py +98 -0
- rlmagent/tui/question_screen.py +52 -0
- rlmagent/tui/replay.py +141 -0
- rlmagent/tui/rlm_agent.tcss +405 -0
- rlmagent/tui/search_box.py +91 -0
- rlmagent/tui/sidebar.py +182 -0
- rlmagent/tui/status.py +49 -0
- rlmagent/tui/steps.py +283 -0
- rlmagent/tui/suggest.py +778 -0
- rlmagent/tui/transcript.py +172 -0
- rlmagent/tui/trust_dialog.py +58 -0
- rlmagent/tui/worker_attach.py +141 -0
- rlmagent/ui/__init__.py +1 -0
- rlmagent/ui/render.py +211 -0
- rlmagent/undo.py +109 -0
- rlmagent/utils/__init__.py +1 -0
- rlmagent/websearch.py +75 -0
- rlmagent/worker.py +1275 -0
- rlmagent/worker_service.py +203 -0
- rlmness_cli-0.2.0.dist-info/METADATA +882 -0
- rlmness_cli-0.2.0.dist-info/RECORD +203 -0
- rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
- rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
- rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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."""
|
rlmagent/hooks/hooks.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Observability / verification hook seam (callbacks on the event stream). TODO."""
|
rlmagent/instructions.py
ADDED
|
@@ -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"]
|