rlmness-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rlmagent/__init__.py +1 -0
- rlmagent/__main__.py +8 -0
- rlmagent/acp/__init__.py +1 -0
- rlmagent/acp/runtime.py +71 -0
- rlmagent/acp/server.py +598 -0
- rlmagent/acp/updates.py +128 -0
- rlmagent/agentnet.py +237 -0
- rlmagent/agents/__init__.py +1 -0
- rlmagent/agents/approval.py +90 -0
- rlmagent/agents/bridge.py +109 -0
- rlmagent/agents/budget.py +212 -0
- rlmagent/agents/child_handles.py +46 -0
- rlmagent/agents/child_mailbox.py +30 -0
- rlmagent/agents/kernel_api.py +156 -0
- rlmagent/agents/output.py +34 -0
- rlmagent/agents/patch.py +107 -0
- rlmagent/agents/prompts.py +156 -0
- rlmagent/agents/trace.py +290 -0
- rlmagent/agents/tree.py +682 -0
- rlmagent/agents/wasm_api.py +105 -0
- rlmagent/agents/workspace.py +357 -0
- rlmagent/autopilot.py +447 -0
- rlmagent/backend.py +946 -0
- rlmagent/backend_context.py +74 -0
- rlmagent/cli/__init__.py +5 -0
- rlmagent/cli/config_cmd.py +177 -0
- rlmagent/cli/health.py +156 -0
- rlmagent/cli/main.py +1194 -0
- rlmagent/cli/update.py +188 -0
- rlmagent/clipboard.py +63 -0
- rlmagent/config/__init__.py +37 -0
- rlmagent/config/config.py +297 -0
- rlmagent/config/loader.py +119 -0
- rlmagent/config/prompts.py +124 -0
- rlmagent/config/secure_input.py +213 -0
- rlmagent/config/store.py +275 -0
- rlmagent/context/__init__.py +47 -0
- rlmagent/context/budget.py +557 -0
- rlmagent/contract.py +311 -0
- rlmagent/contract_run.py +232 -0
- rlmagent/conversation.py +2547 -0
- rlmagent/cron.py +77 -0
- rlmagent/directives.py +893 -0
- rlmagent/discovery.py +356 -0
- rlmagent/execution_log.py +66 -0
- rlmagent/factory.py +189 -0
- rlmagent/factory_graph.py +413 -0
- rlmagent/harness/__init__.py +63 -0
- rlmagent/harness/contracts/__init__.py +1 -0
- rlmagent/harness/contracts/stream.py +129 -0
- rlmagent/harness/contracts/tooling.py +127 -0
- rlmagent/harness/contracts/transcript/__init__.py +68 -0
- rlmagent/harness/contracts/transcript/_base.py +29 -0
- rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
- rlmagent/harness/contracts/transcript/entries.py +180 -0
- rlmagent/harness/contracts/transcript/segments.py +64 -0
- rlmagent/harness/contracts/values.py +6 -0
- rlmagent/harness/driver.py +397 -0
- rlmagent/harness/engine.py +489 -0
- rlmagent/harness/mending.py +153 -0
- rlmagent/harness/provider/__init__.py +1 -0
- rlmagent/harness/provider/base.py +27 -0
- rlmagent/harness/provider/wire.py +189 -0
- rlmagent/harness/py.typed +0 -0
- rlmagent/harness/session/__init__.py +5 -0
- rlmagent/harness/session/index.py +218 -0
- rlmagent/harness/session/lease.py +66 -0
- rlmagent/harness/session/records.py +118 -0
- rlmagent/harness/session/replay.py +165 -0
- rlmagent/harness/session/store.py +155 -0
- rlmagent/harness/session/summary.py +46 -0
- rlmagent/hints.py +47 -0
- rlmagent/history.py +92 -0
- rlmagent/hooks/__init__.py +1 -0
- rlmagent/hooks/hooks.py +1 -0
- rlmagent/instructions.py +157 -0
- rlmagent/job_runner.py +117 -0
- rlmagent/jobs.py +365 -0
- rlmagent/kept_agents.py +397 -0
- rlmagent/kernel/__init__.py +5 -0
- rlmagent/kernel/session.py +205 -0
- rlmagent/kernel/wasm.py +169 -0
- rlmagent/logs.py +208 -0
- rlmagent/managed_release.py +368 -0
- rlmagent/mcp.py +411 -0
- rlmagent/mcp_oauth.py +213 -0
- rlmagent/model/__init__.py +29 -0
- rlmagent/model/_claude/__init__.py +1 -0
- rlmagent/model/_claude/courier.py +117 -0
- rlmagent/model/_claude/emitter.py +378 -0
- rlmagent/model/_oai/__init__.py +1 -0
- rlmagent/model/_oai/helpers.py +256 -0
- rlmagent/model/_oai/normalize.py +263 -0
- rlmagent/model/_oai/parsers.py +317 -0
- rlmagent/model/_oai/payloads.py +226 -0
- rlmagent/model/_oai/transport.py +127 -0
- rlmagent/model/claude.py +552 -0
- rlmagent/model/copilot.py +142 -0
- rlmagent/model/correlation.py +21 -0
- rlmagent/model/deadline.py +19 -0
- rlmagent/model/discovery.py +181 -0
- rlmagent/model/external/__init__.py +11 -0
- rlmagent/model/external/acp.py +202 -0
- rlmagent/model/external/acp_install.py +109 -0
- rlmagent/model/external/cli.py +248 -0
- rlmagent/model/external/textcalls.py +57 -0
- rlmagent/model/failover.py +129 -0
- rlmagent/model/google.py +199 -0
- rlmagent/model/limits.py +46 -0
- rlmagent/model/oai_compatible.py +414 -0
- rlmagent/model/preferences.py +56 -0
- rlmagent/model/py.typed +0 -0
- rlmagent/model/registered_oauth.py +195 -0
- rlmagent/model/scripted.py +55 -0
- rlmagent/model/settings.py +271 -0
- rlmagent/model/subscription.py +398 -0
- rlmagent/model/token_bounds.py +15 -0
- rlmagent/model/transport/__init__.py +1 -0
- rlmagent/model/transport/backoff.py +87 -0
- rlmagent/model/transport/client.py +87 -0
- rlmagent/model/transport/faults.py +65 -0
- rlmagent/model/transport/responses_socket.py +223 -0
- rlmagent/model/vertex.py +93 -0
- rlmagent/network.py +123 -0
- rlmagent/packages.py +246 -0
- rlmagent/planning.py +84 -0
- rlmagent/playbook/__init__.py +1 -0
- rlmagent/playbook/apply.py +106 -0
- rlmagent/playbook/digest.py +63 -0
- rlmagent/playbook/improver.py +272 -0
- rlmagent/playbook/plan.py +99 -0
- rlmagent/playbook/store.py +67 -0
- rlmagent/plugins.py +327 -0
- rlmagent/policy.py +105 -0
- rlmagent/prompt_refs.py +57 -0
- rlmagent/prompts.py +331 -0
- rlmagent/prune.py +60 -0
- rlmagent/py.typed +0 -0
- rlmagent/reasoning.py +135 -0
- rlmagent/refinement/__init__.py +1 -0
- rlmagent/refinement/controller.py +448 -0
- rlmagent/refinement/engine.py +220 -0
- rlmagent/refinement/evidence.py +131 -0
- rlmagent/refinement/scoped.py +90 -0
- rlmagent/refinement/store.py +193 -0
- rlmagent/refresh.py +202 -0
- rlmagent/routing.py +52 -0
- rlmagent/runtime.py +575 -0
- rlmagent/safety.py +267 -0
- rlmagent/session_html.py +57 -0
- rlmagent/session_import.py +52 -0
- rlmagent/session_share.py +55 -0
- rlmagent/sessions.py +249 -0
- rlmagent/shell_sandbox.py +88 -0
- rlmagent/skillset.py +320 -0
- rlmagent/steering.py +148 -0
- rlmagent/stops.py +62 -0
- rlmagent/subscriptions.py +219 -0
- rlmagent/tasks.py +98 -0
- rlmagent/toolhost.py +310 -0
- rlmagent/tools/__init__.py +56 -0
- rlmagent/tools/_shared.py +210 -0
- rlmagent/tools/files.py +462 -0
- rlmagent/tools/kernel.py +93 -0
- rlmagent/tools/patch_tool.py +68 -0
- rlmagent/tools/search.py +188 -0
- rlmagent/trust.py +383 -0
- rlmagent/tui/__init__.py +1 -0
- rlmagent/tui/adapter.py +506 -0
- rlmagent/tui/app.py +711 -0
- rlmagent/tui/approval_screen.py +57 -0
- rlmagent/tui/choice_screen.py +47 -0
- rlmagent/tui/completions.py +116 -0
- rlmagent/tui/components.py +25 -0
- rlmagent/tui/composer.py +40 -0
- rlmagent/tui/factory_view.py +86 -0
- rlmagent/tui/inspect.py +193 -0
- rlmagent/tui/messages.py +215 -0
- rlmagent/tui/model_screen.py +307 -0
- rlmagent/tui/prompt.py +98 -0
- rlmagent/tui/question_screen.py +52 -0
- rlmagent/tui/replay.py +141 -0
- rlmagent/tui/rlm_agent.tcss +405 -0
- rlmagent/tui/search_box.py +91 -0
- rlmagent/tui/sidebar.py +182 -0
- rlmagent/tui/status.py +49 -0
- rlmagent/tui/steps.py +283 -0
- rlmagent/tui/suggest.py +778 -0
- rlmagent/tui/transcript.py +172 -0
- rlmagent/tui/trust_dialog.py +58 -0
- rlmagent/tui/worker_attach.py +141 -0
- rlmagent/ui/__init__.py +1 -0
- rlmagent/ui/render.py +211 -0
- rlmagent/undo.py +109 -0
- rlmagent/utils/__init__.py +1 -0
- rlmagent/websearch.py +75 -0
- rlmagent/worker.py +1275 -0
- rlmagent/worker_service.py +203 -0
- rlmness_cli-0.2.0.dist-info/METADATA +882 -0
- rlmness_cli-0.2.0.dist-info/RECORD +203 -0
- rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
- rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
- rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
rlmagent/conversation.py
ADDED
|
@@ -0,0 +1,2547 @@
|
|
|
1
|
+
"""CodingSession — the single stateful runtime object for a persistent coding session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import importlib.metadata
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import AsyncIterator, Callable
|
|
13
|
+
from dataclasses import dataclass, replace
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from time import time
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from rlmagent.context.budget import (
|
|
19
|
+
DEFAULT_WINDOW,
|
|
20
|
+
ContextEstimate,
|
|
21
|
+
ContextLimits,
|
|
22
|
+
apply_compaction,
|
|
23
|
+
build_summary_prompts,
|
|
24
|
+
estimate_context,
|
|
25
|
+
exceeds_threshold,
|
|
26
|
+
plan_compaction,
|
|
27
|
+
resolve_window,
|
|
28
|
+
)
|
|
29
|
+
from rlmagent.agents.prompts import COMPACTION_NOTE, RESUMED_NOTICE
|
|
30
|
+
from rlmagent.directives import build_default_registry, parse_command
|
|
31
|
+
from rlmagent.instructions import PromptSection
|
|
32
|
+
from rlmagent.routing import ROUTED_PROVIDER, RoutePin, is_route_failure, pin_from_storage
|
|
33
|
+
from rlmagent.harness.contracts.stream import (
|
|
34
|
+
AgentEvent,
|
|
35
|
+
MessageEndEvent,
|
|
36
|
+
MessageStartEvent,
|
|
37
|
+
RetryEvent,
|
|
38
|
+
RunEndEvent,
|
|
39
|
+
)
|
|
40
|
+
from rlmagent.harness.contracts.tooling import ToolSpec
|
|
41
|
+
from rlmagent.harness.contracts.transcript import (
|
|
42
|
+
HumanEntry,
|
|
43
|
+
ModelEntry,
|
|
44
|
+
PruneSummaryEntry,
|
|
45
|
+
ShellResultEntry,
|
|
46
|
+
TranscriptEntry,
|
|
47
|
+
surface_text,
|
|
48
|
+
)
|
|
49
|
+
from rlmagent.harness.contracts.values import JValue
|
|
50
|
+
from rlmagent.harness.driver import (
|
|
51
|
+
PendingBatch,
|
|
52
|
+
QueueShiftEvent,
|
|
53
|
+
RuntimeConfig,
|
|
54
|
+
RuntimeHarness,
|
|
55
|
+
)
|
|
56
|
+
from rlmagent.harness.mending import HistoryMend, mend_tool_history
|
|
57
|
+
from rlmagent.harness.provider.base import ModelProvider
|
|
58
|
+
from rlmagent.harness.provider.wire import StreamCloseEvent
|
|
59
|
+
from rlmagent.harness.session.index import SessionCatalog
|
|
60
|
+
from rlmagent.harness.session.records import (
|
|
61
|
+
ExtensionRecord,
|
|
62
|
+
ForkSummaryRecord,
|
|
63
|
+
ModelSwapRecord,
|
|
64
|
+
PruneRecord,
|
|
65
|
+
ReasoningLevelRecord,
|
|
66
|
+
SessionMetaRecord,
|
|
67
|
+
SessionRecord,
|
|
68
|
+
TagRecord,
|
|
69
|
+
TipRecord,
|
|
70
|
+
TranscriptRecord,
|
|
71
|
+
mint_id,
|
|
72
|
+
)
|
|
73
|
+
from rlmagent.harness.session.replay import (
|
|
74
|
+
TreeIntegrityError,
|
|
75
|
+
find_tip,
|
|
76
|
+
project_active_branch,
|
|
77
|
+
project_records,
|
|
78
|
+
trace_to_entry,
|
|
79
|
+
)
|
|
80
|
+
from rlmagent.harness.session.store import JsonlVault
|
|
81
|
+
|
|
82
|
+
# ── context-window budget ──────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
#: Context accounting and compaction policy live in ``rlmagent.context.budget``.
|
|
85
|
+
#: The session owns one immutable ``ContextLimits`` and delegates every estimate,
|
|
86
|
+
#: threshold check, and compaction plan to that module.
|
|
87
|
+
_CONTEXT_LIMITS = ContextLimits()
|
|
88
|
+
|
|
89
|
+
#: The single slash-command catalog. Names, aliases, descriptions, and usage
|
|
90
|
+
#: strings all come from here; the session binds its own handlers to them.
|
|
91
|
+
COMMAND_REGISTRY = build_default_registry()
|
|
92
|
+
|
|
93
|
+
#: Registry commands a coding session dispatches, plus the ones a frontend runs
|
|
94
|
+
#: on its behalf: ``/continue`` resumes the loop (which yields events, not a
|
|
95
|
+
#: string), and ``/quit`` and ``/version`` belong to the frontend entirely.
|
|
96
|
+
#: They are listed here so completion and ``/help`` describe everything a user
|
|
97
|
+
#: can actually type. Anything outside this set stays in the registry for other
|
|
98
|
+
#: callers but is never offered here.
|
|
99
|
+
_RUNNABLE_COMMANDS: frozenset[str] = frozenset({
|
|
100
|
+
"branch", "branches", "compact", "continue", "create-skill", "diag", "diff", "tasks", "mcp",
|
|
101
|
+
"goal", "autonomous", "btw", "image",
|
|
102
|
+
"export", "help", "model", "name", "plan", "provider", "quit", "reload",
|
|
103
|
+
"refine", "rewind", "session", "shell", "skills", "stats", "system", "think", "tools", "undo",
|
|
104
|
+
"version", "tier", "fast", "update", "share", "logs", "changelog",
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
_RUNNABLE_COMMAND_TABLE: tuple[tuple[str, str], ...] = tuple(
|
|
108
|
+
(command.name, command.description)
|
|
109
|
+
for command in COMMAND_REGISTRY.all_commands()
|
|
110
|
+
if command.name in _RUNNABLE_COMMANDS
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
_EXTENSION_GROUP = "rlm-agent.extensions"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
_EXPORT_FORMATS = ("text", "json", "jsonl", "html")
|
|
117
|
+
_EXPORT_SUFFIX = {"text": "txt", "json": "json", "jsonl": "jsonl", "html": "html"}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def parse_export_arg(arg: str) -> tuple[str, str | None]:
|
|
121
|
+
"""Split ``/export`` arguments into ``(format, destination)``."""
|
|
122
|
+
parts = arg.split()
|
|
123
|
+
if not parts:
|
|
124
|
+
return "text", None
|
|
125
|
+
|
|
126
|
+
if parts[0].lower() in _EXPORT_FORMATS:
|
|
127
|
+
fmt = parts[0].lower()
|
|
128
|
+
rest = " ".join(parts[1:]).strip()
|
|
129
|
+
return fmt, rest or None
|
|
130
|
+
|
|
131
|
+
destination = arg.strip()
|
|
132
|
+
|
|
133
|
+
# A lone bare word with no separator and no extension is a mistyped
|
|
134
|
+
# format, not a path — returning it as the format lets the caller reject
|
|
135
|
+
# it instead of silently creating a junk file.
|
|
136
|
+
if (
|
|
137
|
+
len(parts) == 1
|
|
138
|
+
and not Path(destination).suffix
|
|
139
|
+
and not any(sep in destination for sep in ("/", "\\", "~", "."))
|
|
140
|
+
):
|
|
141
|
+
return destination.lower(), None
|
|
142
|
+
|
|
143
|
+
# Otherwise infer the format from the file extension.
|
|
144
|
+
suffix = Path(destination).suffix.lstrip(".").lower()
|
|
145
|
+
for name, ext in _EXPORT_SUFFIX.items():
|
|
146
|
+
if suffix == ext:
|
|
147
|
+
return name, destination
|
|
148
|
+
return "text", destination
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
_SKILL_NAME_RE = re.compile(r"\A[a-z0-9][a-z0-9._-]*\Z", re.IGNORECASE)
|
|
152
|
+
_SKILL_DESC_MAX = 100
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def parse_create_skill_arg(arg: str) -> tuple[str, str]:
|
|
156
|
+
"""Split ``/create-skill`` arguments into ``(name, body)``."""
|
|
157
|
+
stripped = arg.strip()
|
|
158
|
+
if not stripped:
|
|
159
|
+
return "", ""
|
|
160
|
+
parts = stripped.split(None, 1)
|
|
161
|
+
name = parts[0]
|
|
162
|
+
body = parts[1].strip() if len(parts) > 1 else ""
|
|
163
|
+
return name, body
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def derive_skill_description(body: str) -> str:
|
|
167
|
+
"""Use the first non-empty line of *body* as the skill description."""
|
|
168
|
+
for line in body.splitlines():
|
|
169
|
+
line = line.strip().lstrip("#").strip()
|
|
170
|
+
if line:
|
|
171
|
+
return line[:_SKILL_DESC_MAX]
|
|
172
|
+
return ""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def render_skill_file(name: str, description: str, body: str) -> str:
|
|
176
|
+
"""Render a ``SKILL.md`` with front matter."""
|
|
177
|
+
return (
|
|
178
|
+
f"---\nname: {name}\ndescription: {description}\n---\n\n{body.rstrip()}\n"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
_TITLE_MAX_CHARS = 48
|
|
183
|
+
|
|
184
|
+
def summarize_prompt(text: str, *, limit: int = _TITLE_MAX_CHARS) -> str:
|
|
185
|
+
"""Condense a prompt into a one-line session title."""
|
|
186
|
+
flat = " ".join(text.split())
|
|
187
|
+
if flat.startswith("/"):
|
|
188
|
+
flat = flat.lstrip("/")
|
|
189
|
+
if not flat:
|
|
190
|
+
return ""
|
|
191
|
+
if len(flat) <= limit:
|
|
192
|
+
return flat
|
|
193
|
+
clipped = flat[:limit]
|
|
194
|
+
if " " in clipped:
|
|
195
|
+
clipped = clipped.rsplit(" ", 1)[0]
|
|
196
|
+
return clipped.rstrip(" ,.;:-") + "…"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ── session statistics ─────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# Phrases providers use when a request exceeds the model's context window.
|
|
203
|
+
_OVERFLOW_PHRASES = (
|
|
204
|
+
"context length",
|
|
205
|
+
"context window",
|
|
206
|
+
"context limit",
|
|
207
|
+
"maximum context",
|
|
208
|
+
"max context",
|
|
209
|
+
"input is too long",
|
|
210
|
+
"input length",
|
|
211
|
+
"prompt is too long",
|
|
212
|
+
"too many tokens",
|
|
213
|
+
"token limit",
|
|
214
|
+
"exceeds the limit",
|
|
215
|
+
"exceeded the limit",
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _is_context_overflow(reply: ModelEntry) -> bool:
|
|
220
|
+
"""Whether a failed reply reports that the prompt did not fit."""
|
|
221
|
+
if reply.stop_reason != "error":
|
|
222
|
+
return False
|
|
223
|
+
text = (reply.error_message or "").lower()
|
|
224
|
+
return any(phrase in text for phrase in _OVERFLOW_PHRASES)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
_HISTORY_REPAIR_NAMESPACE = "rlm-agent.history-repair"
|
|
228
|
+
_SUB_AGENT_NAMESPACE = "rlm-agent.sub-agent"
|
|
229
|
+
_PLAYBOOK_NAMESPACE = "rlm-agent.playbook"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _records_after(
|
|
233
|
+
records: list[SessionRecord], tip_id: str | None, ancestor_id: str | None,
|
|
234
|
+
) -> list[SessionRecord]:
|
|
235
|
+
"""Records on the active path strictly after ``ancestor_id``, root-first."""
|
|
236
|
+
if tip_id is None:
|
|
237
|
+
return []
|
|
238
|
+
try:
|
|
239
|
+
path = trace_to_entry(records, tip_id)
|
|
240
|
+
except TreeIntegrityError:
|
|
241
|
+
return []
|
|
242
|
+
ids = [record.id for record in path]
|
|
243
|
+
start = ids.index(ancestor_id) + 1 if ancestor_id in ids else 0
|
|
244
|
+
return path[start:]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@dataclass(frozen=True, slots=True)
|
|
248
|
+
class SessionTreeChoice:
|
|
249
|
+
"""One branchable entry in the active session tree."""
|
|
250
|
+
|
|
251
|
+
entry_id: str
|
|
252
|
+
label: str
|
|
253
|
+
active: bool = False
|
|
254
|
+
is_tool_call: bool = False
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _short_preview(text: str, *, limit: int = 72) -> str:
|
|
258
|
+
"""Collapse whitespace and clip *text* for single-line display."""
|
|
259
|
+
normalized = " ".join(text.split())
|
|
260
|
+
if len(normalized) <= limit:
|
|
261
|
+
return normalized or "(empty)"
|
|
262
|
+
return f"{normalized[: limit - 1]}..."
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _tree_entry_title(record: SessionRecord) -> str:
|
|
266
|
+
"""Human-readable one-line title for a tree row."""
|
|
267
|
+
match record.type:
|
|
268
|
+
case "message":
|
|
269
|
+
message = record.message
|
|
270
|
+
if (
|
|
271
|
+
isinstance(message, ModelEntry)
|
|
272
|
+
and message.tool_calls
|
|
273
|
+
and not message.text.strip()
|
|
274
|
+
):
|
|
275
|
+
names = ", ".join(call.name for call in message.tool_calls)
|
|
276
|
+
return f"tool call: {names}"
|
|
277
|
+
return f"{message.role}: {_short_preview(surface_text(message))}"
|
|
278
|
+
case "compaction":
|
|
279
|
+
return f"compaction summary: {_short_preview(record.summary)}"
|
|
280
|
+
case "branch_summary":
|
|
281
|
+
return f"branch summary: {_short_preview(record.summary)}"
|
|
282
|
+
case _:
|
|
283
|
+
return record.type
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _is_branchable_record(record: SessionRecord) -> bool:
|
|
287
|
+
"""Whether a rewind/branch may target *record*."""
|
|
288
|
+
if record.type in {"compaction", "branch_summary"}:
|
|
289
|
+
return True
|
|
290
|
+
if record.type != "message":
|
|
291
|
+
return False
|
|
292
|
+
return isinstance(record.message, HumanEntry | ModelEntry)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _is_tool_call_record(record: SessionRecord) -> bool:
|
|
296
|
+
"""Whether *record* holds an assistant turn that requested tools."""
|
|
297
|
+
return (
|
|
298
|
+
record.type == "message"
|
|
299
|
+
and isinstance(record.message, ModelEntry)
|
|
300
|
+
and bool(record.message.tool_calls)
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _ordered_tree_records(records: list[SessionRecord]) -> tuple[SessionRecord, ...]:
|
|
305
|
+
"""Depth-first ordering of the record tree, roots first."""
|
|
306
|
+
children: dict[str | None, list[SessionRecord]] = {}
|
|
307
|
+
for record in records:
|
|
308
|
+
if record.type != "leaf":
|
|
309
|
+
children.setdefault(record.parent_id, []).append(record)
|
|
310
|
+
|
|
311
|
+
ordered: list[SessionRecord] = []
|
|
312
|
+
seen: set[str] = set()
|
|
313
|
+
expanded: set[str | None] = set()
|
|
314
|
+
|
|
315
|
+
def walk(root_parent_id: str | None) -> None:
|
|
316
|
+
stack: list[str | None] = [root_parent_id]
|
|
317
|
+
while stack:
|
|
318
|
+
parent_id = stack.pop()
|
|
319
|
+
if parent_id in expanded:
|
|
320
|
+
continue
|
|
321
|
+
expanded.add(parent_id)
|
|
322
|
+
kids = children.get(parent_id, [])
|
|
323
|
+
for child in kids:
|
|
324
|
+
if child.id not in seen:
|
|
325
|
+
ordered.append(child)
|
|
326
|
+
seen.add(child.id)
|
|
327
|
+
for child in reversed(kids):
|
|
328
|
+
stack.append(child.id)
|
|
329
|
+
|
|
330
|
+
walk(None)
|
|
331
|
+
for record in records:
|
|
332
|
+
if record.type != "leaf" and record.id not in seen:
|
|
333
|
+
ordered.append(record)
|
|
334
|
+
seen.add(record.id)
|
|
335
|
+
walk(record.id)
|
|
336
|
+
return tuple(ordered)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _tree_branch_indents(records: list[SessionRecord]) -> dict[str, int]:
|
|
340
|
+
"""Indent depth per record: only forks (2nd+ sibling) add a level."""
|
|
341
|
+
children: dict[str | None, list[str]] = {}
|
|
342
|
+
for record in records:
|
|
343
|
+
if record.type != "leaf":
|
|
344
|
+
children.setdefault(record.parent_id, []).append(record.id)
|
|
345
|
+
|
|
346
|
+
sibling_index = {
|
|
347
|
+
child_id: index
|
|
348
|
+
for kids in children.values()
|
|
349
|
+
for index, child_id in enumerate(kids)
|
|
350
|
+
}
|
|
351
|
+
indents: dict[str, int] = {}
|
|
352
|
+
for record in records:
|
|
353
|
+
if record.type == "leaf":
|
|
354
|
+
continue
|
|
355
|
+
parent_indent = (
|
|
356
|
+
indents.get(record.parent_id, 0) if record.parent_id is not None else 0
|
|
357
|
+
)
|
|
358
|
+
indents[record.id] = parent_indent + (
|
|
359
|
+
1 if sibling_index.get(record.id, 0) > 0 else 0
|
|
360
|
+
)
|
|
361
|
+
return indents
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _tree_choice_label(record: SessionRecord, *, branch_indent: int = 0) -> str:
|
|
365
|
+
"""Indented display label for one tree row."""
|
|
366
|
+
return f"{' ' * branch_indent}{_tree_entry_title(record)}"
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@dataclass(frozen=True, slots=True)
|
|
370
|
+
class SessionStats:
|
|
371
|
+
"""Cumulative usage statistics for a coding session."""
|
|
372
|
+
|
|
373
|
+
turn_count: int = 0
|
|
374
|
+
message_count: int = 0
|
|
375
|
+
total_input_tokens: int = 0
|
|
376
|
+
total_output_tokens: int = 0
|
|
377
|
+
total_cost_usd: float = 0.0
|
|
378
|
+
estimated_context_tokens: int = 0
|
|
379
|
+
context_limit: int = DEFAULT_WINDOW
|
|
380
|
+
cache_read_tokens: int = 0
|
|
381
|
+
cache_write_tokens: int = 0
|
|
382
|
+
# Prompt size and cached share of the most recent model request.
|
|
383
|
+
latest_prompt_tokens: int = 0
|
|
384
|
+
latest_cache_read_tokens: int = 0
|
|
385
|
+
|
|
386
|
+
@property
|
|
387
|
+
def cache_hit_rate(self) -> float | None:
|
|
388
|
+
"""Share of all prompt tokens this session served from the cache."""
|
|
389
|
+
if not self._cache_active or self.total_input_tokens <= 0:
|
|
390
|
+
return None
|
|
391
|
+
return self.cache_read_tokens / self.total_input_tokens
|
|
392
|
+
|
|
393
|
+
@property
|
|
394
|
+
def latest_cache_hit_rate(self) -> float | None:
|
|
395
|
+
"""Share of the latest request's prompt served from the cache."""
|
|
396
|
+
if not self._cache_active or self.latest_prompt_tokens <= 0:
|
|
397
|
+
return None
|
|
398
|
+
return self.latest_cache_read_tokens / self.latest_prompt_tokens
|
|
399
|
+
|
|
400
|
+
@property
|
|
401
|
+
def _cache_active(self) -> bool:
|
|
402
|
+
return self.cache_read_tokens > 0 or self.cache_write_tokens > 0
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
# ── main class ─────────────────────────────────────────────────────────────
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
MAX_SAVED_UNDO = 50
|
|
409
|
+
MAX_SAVED_UNDO_STATES = 5
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
class CodingSession:
|
|
413
|
+
"""Single stateful runtime object for a persistent, resumable coding session."""
|
|
414
|
+
|
|
415
|
+
def __init__(
|
|
416
|
+
self,
|
|
417
|
+
*,
|
|
418
|
+
session_id: str,
|
|
419
|
+
vault: JsonlVault | None,
|
|
420
|
+
catalog: SessionCatalog | None = None,
|
|
421
|
+
provider: ModelProvider,
|
|
422
|
+
provider_name: str,
|
|
423
|
+
model: str,
|
|
424
|
+
system: str,
|
|
425
|
+
tools: list[ToolSpec],
|
|
426
|
+
transcript: list[TranscriptEntry] | None = None,
|
|
427
|
+
record_ids: list[str] | None = None,
|
|
428
|
+
tip_id: str | None = None,
|
|
429
|
+
title: str | None = None,
|
|
430
|
+
thinking_level: str | None = None,
|
|
431
|
+
cwd: str | None = None,
|
|
432
|
+
tools_loader: Callable[[], list[ToolSpec]] | None = None,
|
|
433
|
+
skills_loader: Callable[[], list[Any]] | None = None,
|
|
434
|
+
templates_loader: Callable[[], list[Any]] | None = None,
|
|
435
|
+
) -> None:
|
|
436
|
+
self._session_id = session_id
|
|
437
|
+
self._vault = vault
|
|
438
|
+
from rlmagent.steering import Steering
|
|
439
|
+
|
|
440
|
+
self.steering = Steering(vault.path.with_suffix(".steering.json") if vault else None)
|
|
441
|
+
self._catalog = catalog
|
|
442
|
+
self._pending_header: SessionMetaRecord | None = None
|
|
443
|
+
self._provider = provider
|
|
444
|
+
self._provider_name = provider_name
|
|
445
|
+
self._model = model
|
|
446
|
+
self._system = system
|
|
447
|
+
self._title = title
|
|
448
|
+
self._thinking_level = thinking_level
|
|
449
|
+
self._cwd = cwd or os.getcwd()
|
|
450
|
+
self._record_ids: list[str] = list(record_ids) if record_ids else []
|
|
451
|
+
# The tip is the head of the active branch. It is not always the last
|
|
452
|
+
# transcript record — metadata records (title, model, prune) also chain
|
|
453
|
+
# onto it — so callers pass it explicitly when known.
|
|
454
|
+
self._tip_id: str | None = tip_id or (
|
|
455
|
+
self._record_ids[-1] if self._record_ids else None
|
|
456
|
+
)
|
|
457
|
+
self._extensions: list[object] = []
|
|
458
|
+
self._named = title is not None
|
|
459
|
+
self.shadow = None
|
|
460
|
+
self._undo_stack: list[tuple[str, str | None, list, dict | None]] = []
|
|
461
|
+
self._renamed_manually = False
|
|
462
|
+
self._tools_loader = tools_loader
|
|
463
|
+
self._skills_loader = skills_loader
|
|
464
|
+
self._skills: list[Any] = list(skills_loader() if skills_loader else [])
|
|
465
|
+
self._templates_loader = templates_loader
|
|
466
|
+
self._templates: list[Any] = list(
|
|
467
|
+
templates_loader() if templates_loader else []
|
|
468
|
+
)
|
|
469
|
+
# Unrestricted tool set. Plan mode narrows what the harness sees, so
|
|
470
|
+
# the full list is kept here to restore from and to hand to new
|
|
471
|
+
# sessions.
|
|
472
|
+
self._all_tools: list[ToolSpec] = list(tools)
|
|
473
|
+
self._plan_mode = False
|
|
474
|
+
# Backend pin for routed providers; see ``rlmagent.routing``.
|
|
475
|
+
self._route = RoutePin()
|
|
476
|
+
|
|
477
|
+
# Cumulative stats
|
|
478
|
+
self._turn_count = 0
|
|
479
|
+
self._total_input = 0
|
|
480
|
+
self._total_output = 0
|
|
481
|
+
self._total_cost = 0.0
|
|
482
|
+
self._cache_read = 0
|
|
483
|
+
self._cache_write = 0
|
|
484
|
+
self._latest_prompt = 0
|
|
485
|
+
self._latest_cache_read = 0
|
|
486
|
+
|
|
487
|
+
# Build harness
|
|
488
|
+
config = RuntimeConfig(
|
|
489
|
+
provider=provider,
|
|
490
|
+
model=model,
|
|
491
|
+
system=system,
|
|
492
|
+
tools=list(tools),
|
|
493
|
+
cache_key=session_id,
|
|
494
|
+
)
|
|
495
|
+
self._harness = RuntimeHarness(config, messages=transcript or [])
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
self._unsubscribe = self._harness.on_event(self._on_harness_event)
|
|
499
|
+
|
|
500
|
+
# A resumed session restores its thinking level from the record log;
|
|
501
|
+
# apply it so the first request matches what the transcript claims.
|
|
502
|
+
self._apply_thinking_level()
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
if transcript:
|
|
506
|
+
for entry in transcript:
|
|
507
|
+
if isinstance(entry, ModelEntry):
|
|
508
|
+
self._tally(entry)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
@classmethod
|
|
513
|
+
async def create(
|
|
514
|
+
cls,
|
|
515
|
+
*,
|
|
516
|
+
provider: ModelProvider,
|
|
517
|
+
provider_name: str,
|
|
518
|
+
model: str,
|
|
519
|
+
system: str,
|
|
520
|
+
tools: list[ToolSpec] | None = None,
|
|
521
|
+
sessions_dir: Path | None = None,
|
|
522
|
+
session_id: str | None = None,
|
|
523
|
+
cwd: str | None = None,
|
|
524
|
+
tools_loader: Callable[[], list[ToolSpec]] | None = None,
|
|
525
|
+
skills_loader: Callable[[], list[Any]] | None = None,
|
|
526
|
+
templates_loader: Callable[[], list[Any]] | None = None,
|
|
527
|
+
) -> CodingSession:
|
|
528
|
+
"""Create a new coding session with optional persistence."""
|
|
529
|
+
sid = session_id or mint_id()[:12]
|
|
530
|
+
vault: JsonlVault | None = None
|
|
531
|
+
catalog: SessionCatalog | None = None
|
|
532
|
+
root_id: str | None = None
|
|
533
|
+
header: SessionMetaRecord | None = None
|
|
534
|
+
effective_cwd = cwd or os.getcwd()
|
|
535
|
+
if sessions_dir is not None:
|
|
536
|
+
vault = JsonlVault(sessions_dir / f"{sid}.jsonl")
|
|
537
|
+
# The metadata record is the tree root; everything else descends
|
|
538
|
+
# from it so a parent-chain walk always reaches session cwd/title.
|
|
539
|
+
# Nothing is written until the first record, so a session that is
|
|
540
|
+
# opened and left never shows up as an empty resumable one.
|
|
541
|
+
header = SessionMetaRecord(cwd=effective_cwd)
|
|
542
|
+
root_id = header.id
|
|
543
|
+
catalog = SessionCatalog(sessions_dir)
|
|
544
|
+
session = cls(
|
|
545
|
+
session_id=sid,
|
|
546
|
+
vault=vault,
|
|
547
|
+
catalog=catalog,
|
|
548
|
+
provider=provider,
|
|
549
|
+
provider_name=provider_name,
|
|
550
|
+
model=model,
|
|
551
|
+
system=system,
|
|
552
|
+
tools=tools or [],
|
|
553
|
+
tip_id=root_id,
|
|
554
|
+
cwd=cwd,
|
|
555
|
+
tools_loader=tools_loader,
|
|
556
|
+
skills_loader=skills_loader,
|
|
557
|
+
templates_loader=templates_loader,
|
|
558
|
+
)
|
|
559
|
+
if vault is not None:
|
|
560
|
+
session._pending_header = header
|
|
561
|
+
return session
|
|
562
|
+
|
|
563
|
+
@classmethod
|
|
564
|
+
async def resume(
|
|
565
|
+
cls,
|
|
566
|
+
session_id: str,
|
|
567
|
+
*,
|
|
568
|
+
provider: ModelProvider,
|
|
569
|
+
provider_name: str,
|
|
570
|
+
model: str,
|
|
571
|
+
system: str,
|
|
572
|
+
tools: list[ToolSpec] | None = None,
|
|
573
|
+
sessions_dir: Path,
|
|
574
|
+
tools_loader: Callable[[], list[ToolSpec]] | None = None,
|
|
575
|
+
skills_loader: Callable[[], list[Any]] | None = None,
|
|
576
|
+
templates_loader: Callable[[], list[Any]] | None = None,
|
|
577
|
+
keep_model: bool = False,
|
|
578
|
+
) -> CodingSession:
|
|
579
|
+
"""Load an existing session from persistent storage."""
|
|
580
|
+
vault = JsonlVault(sessions_dir / f"{session_id}.jsonl")
|
|
581
|
+
if not vault.path.exists():
|
|
582
|
+
raise FileNotFoundError(f"Session not found: {session_id}")
|
|
583
|
+
|
|
584
|
+
records = await vault.read_all()
|
|
585
|
+
# Reconstruct along the active-leaf path, not raw write order: once a
|
|
586
|
+
# session has branched, the file also holds records from abandoned
|
|
587
|
+
# branches. Walking parent pointers from the tip keeps those out, and
|
|
588
|
+
# a damaged chain degrades to a flat replay rather than failing to load.
|
|
589
|
+
state = project_active_branch(records)
|
|
590
|
+
tip_id = find_tip(records)
|
|
591
|
+
|
|
592
|
+
catalog = SessionCatalog(sessions_dir)
|
|
593
|
+
catalog.touch(session_id)
|
|
594
|
+
meta = catalog.get(session_id)
|
|
595
|
+
# The recorded model belongs to the provider the session last used.
|
|
596
|
+
# Under a different provider it would be rejected, so keep the
|
|
597
|
+
# active provider's model; an explicit choice always wins.
|
|
598
|
+
stored = meta.provider if meta is not None else None
|
|
599
|
+
if not keep_model and (stored is None or stored == provider_name):
|
|
600
|
+
# The starting model lives in the index; later switches in the log.
|
|
601
|
+
model = state.model or (meta.model if meta is not None else None) or model
|
|
602
|
+
|
|
603
|
+
session = cls(
|
|
604
|
+
session_id=session_id,
|
|
605
|
+
vault=vault,
|
|
606
|
+
catalog=catalog,
|
|
607
|
+
provider=provider,
|
|
608
|
+
provider_name=provider_name,
|
|
609
|
+
model=model,
|
|
610
|
+
system=system,
|
|
611
|
+
tools=tools or [],
|
|
612
|
+
transcript=state.transcript,
|
|
613
|
+
record_ids=state.record_ids,
|
|
614
|
+
tip_id=tip_id,
|
|
615
|
+
title=state.title,
|
|
616
|
+
thinking_level=state.thinking_level,
|
|
617
|
+
cwd=state.cwd,
|
|
618
|
+
tools_loader=tools_loader,
|
|
619
|
+
skills_loader=skills_loader,
|
|
620
|
+
templates_loader=templates_loader,
|
|
621
|
+
)
|
|
622
|
+
if meta is not None:
|
|
623
|
+
session._route = pin_from_storage(
|
|
624
|
+
meta.inference_provider, meta.inference_provider_mode,
|
|
625
|
+
)
|
|
626
|
+
session._apply_route()
|
|
627
|
+
await session._mend_stored_history()
|
|
628
|
+
# A kernel cannot be saved, so a resumed session starts a fresh one.
|
|
629
|
+
if not getattr(provider, "is_backend", False):
|
|
630
|
+
session.inject(RESUMED_NOTICE)
|
|
631
|
+
return session
|
|
632
|
+
|
|
633
|
+
# ── run lifecycle ──────────────────────────────────────────────────
|
|
634
|
+
|
|
635
|
+
def submit(self, text: str) -> AsyncIterator[AgentEvent]:
|
|
636
|
+
"""Submit a user prompt and stream agent events."""
|
|
637
|
+
refinement = getattr(self, "refinement", None)
|
|
638
|
+
if refinement is not None:
|
|
639
|
+
refinement.at_boundary()
|
|
640
|
+
pilot = getattr(self, "autopilot", None)
|
|
641
|
+
if pilot is not None and (reminder := pilot.reminder()):
|
|
642
|
+
self.inject(reminder)
|
|
643
|
+
# Name the session from its first prompt straight away, so listings
|
|
644
|
+
# never show a bare id while waiting on the model-generated title.
|
|
645
|
+
if not self._title:
|
|
646
|
+
self.set_provisional_title(text)
|
|
647
|
+
# `/skill:<name>` and `/<template-name>` are expanded into their full
|
|
648
|
+
# text before reaching the model; the title keeps the short form.
|
|
649
|
+
# Skills are checked first: their `skill:` prefix cannot collide with a
|
|
650
|
+
# template name, so the cheaper, unambiguous match goes ahead.
|
|
651
|
+
prompt = self.expand_skill(text) or self.expand_template(text) or text
|
|
652
|
+
return self._turn(text, prompt)
|
|
653
|
+
|
|
654
|
+
async def _turn(self, text: str, prompt: str) -> AsyncIterator[AgentEvent]:
|
|
655
|
+
prompt += await self._before_turn(text)
|
|
656
|
+
images = getattr(self, "pending_images", None)
|
|
657
|
+
content = prompt
|
|
658
|
+
if images:
|
|
659
|
+
from rlmagent.harness.contracts.transcript import TextSegment
|
|
660
|
+
|
|
661
|
+
content = [TextSegment(text=prompt), *images]
|
|
662
|
+
self.pending_images = []
|
|
663
|
+
async for event in self._run_wrapped(self._harness.submit(content)):
|
|
664
|
+
yield event
|
|
665
|
+
|
|
666
|
+
async def _before_turn(self, text: str) -> str:
|
|
667
|
+
"""Snapshot the workspace for /undo; preload the files and links the prompt names."""
|
|
668
|
+
if self.shadow is not None and self.shadow.available:
|
|
669
|
+
try:
|
|
670
|
+
commit = await asyncio.to_thread(self.shadow.snapshot, f"before: {text[:80]}")
|
|
671
|
+
backend = getattr(self, "backend", None)
|
|
672
|
+
state = backend.checkpoint_state() if backend is not None else None
|
|
673
|
+
self._undo_stack.append(
|
|
674
|
+
(commit, self._tip_id, list(self._harness.transcript), state))
|
|
675
|
+
self._save_undo()
|
|
676
|
+
self._undo_problem = None
|
|
677
|
+
except (RuntimeError, OSError, subprocess.TimeoutExpired) as exc:
|
|
678
|
+
self._undo_problem = str(exc) or type(exc).__name__
|
|
679
|
+
print(f"rlm-agent: /undo will not cover this turn: {self._undo_problem}",
|
|
680
|
+
file=sys.stderr)
|
|
681
|
+
tree = getattr(self, "agent_tree", None)
|
|
682
|
+
if tree is None or not self.cwd:
|
|
683
|
+
return ""
|
|
684
|
+
from rlmagent.prompt_refs import note, references
|
|
685
|
+
|
|
686
|
+
refs = references(text, self.cwd)
|
|
687
|
+
backend = getattr(self, "backend", None)
|
|
688
|
+
if backend is not None:
|
|
689
|
+
backend.prompt_context = refs
|
|
690
|
+
return note(refs)
|
|
691
|
+
# What the root works from; the delegation guard compares sub-agent contexts to it.
|
|
692
|
+
tree.nodes[tree.root_id()].context = refs if refs["files"] else text
|
|
693
|
+
if not refs["files"] and not refs["links"]:
|
|
694
|
+
return ""
|
|
695
|
+
literal = json.dumps(json.dumps(refs))
|
|
696
|
+
await tree.root_kernel().run(f"import json as _j\nPROMPT = _j.loads({literal})\ndel _j")
|
|
697
|
+
return note(refs)
|
|
698
|
+
|
|
699
|
+
def _undo_file(self) -> Path | None:
|
|
700
|
+
vault = getattr(self, "_vault", None)
|
|
701
|
+
return vault.path.with_suffix(".undo.json") if vault is not None else None
|
|
702
|
+
|
|
703
|
+
def _save_undo(self) -> None:
|
|
704
|
+
"""Keep the turns /undo can reach beside the session, so they survive a restart."""
|
|
705
|
+
path = self._undo_file()
|
|
706
|
+
if path is None:
|
|
707
|
+
return
|
|
708
|
+
kept = self._undo_stack[-MAX_SAVED_UNDO:]
|
|
709
|
+
entries = [{"commit": commit, "tip": tip,
|
|
710
|
+
# Variables can be large: only the latest turns keep theirs.
|
|
711
|
+
"state": state if i >= len(kept) - MAX_SAVED_UNDO_STATES else None}
|
|
712
|
+
for i, (commit, tip, _messages, state) in enumerate(kept)]
|
|
713
|
+
data = json.dumps({"cwd": self.cwd, "entries": entries}, default=str)
|
|
714
|
+
staged = path.with_name(path.name + ".tmp")
|
|
715
|
+
try:
|
|
716
|
+
# The first turn of a new session can come before its folder exists.
|
|
717
|
+
staged.parent.mkdir(parents=True, exist_ok=True)
|
|
718
|
+
staged.write_text(data, encoding="utf-8")
|
|
719
|
+
staged.replace(path)
|
|
720
|
+
except OSError as exc:
|
|
721
|
+
print(f"rlm-agent: /undo will not survive a restart: {exc}", file=sys.stderr)
|
|
722
|
+
|
|
723
|
+
def load_undo(self) -> None:
|
|
724
|
+
"""Bring back the turns saved for /undo, if they belong to this folder."""
|
|
725
|
+
path = self._undo_file()
|
|
726
|
+
try:
|
|
727
|
+
saved = json.loads(path.read_text(encoding="utf-8")) if path is not None else None
|
|
728
|
+
except (OSError, ValueError):
|
|
729
|
+
return
|
|
730
|
+
if not saved or saved.get("cwd") != self.cwd:
|
|
731
|
+
return
|
|
732
|
+
# A turn with no tip began an empty conversation, so going back means no messages.
|
|
733
|
+
self._undo_stack = [(e["commit"], e.get("tip"), [], e.get("state"))
|
|
734
|
+
for e in saved.get("entries", [])]
|
|
735
|
+
|
|
736
|
+
@property
|
|
737
|
+
def interrupted(self) -> bool:
|
|
738
|
+
"""The saved conversation stops mid-turn: the app quit or crashed, or the user stopped it."""
|
|
739
|
+
for entry in reversed(self._harness.transcript):
|
|
740
|
+
role = getattr(entry, "role", "")
|
|
741
|
+
if role == "assistant":
|
|
742
|
+
return entry.stop_reason in ("aborted", "toolUse")
|
|
743
|
+
if role in ("user", "toolResult"):
|
|
744
|
+
return True
|
|
745
|
+
return False
|
|
746
|
+
|
|
747
|
+
def _why_no_undo(self, what: str) -> str:
|
|
748
|
+
reason = getattr(self.shadow, "reason", "") if self.shadow is not None else ""
|
|
749
|
+
return reason.replace("Undo", what, 1) if reason else f"{what} needs git on PATH."
|
|
750
|
+
|
|
751
|
+
async def _cmd_diff(self, arg: str) -> str:
|
|
752
|
+
arg = arg.strip()
|
|
753
|
+
if arg not in ("", "last", "stat", "last stat"):
|
|
754
|
+
return "Usage: /diff [last | stat]"
|
|
755
|
+
if not self._undo_stack:
|
|
756
|
+
ready = self.shadow is not None and self.shadow.available
|
|
757
|
+
return "Nothing has changed yet." if ready else self._why_no_undo("Diff")
|
|
758
|
+
commit = self._undo_stack[-1 if arg.startswith("last") else 0][0]
|
|
759
|
+
try:
|
|
760
|
+
text = await asyncio.to_thread(self.shadow.diff, commit, arg.endswith("stat"))
|
|
761
|
+
except (RuntimeError, OSError, subprocess.TimeoutExpired) as exc:
|
|
762
|
+
return f"Diff failed: {exc}"
|
|
763
|
+
if not text:
|
|
764
|
+
return "No changes in the project."
|
|
765
|
+
limit = 20_000
|
|
766
|
+
return text if len(text) <= limit else (
|
|
767
|
+
text[:limit] + f"\n... cut at {limit} characters; /diff stat lists every file")
|
|
768
|
+
|
|
769
|
+
async def _cmd_mcp(self, arg: str) -> str:
|
|
770
|
+
hub = getattr(self, "mcp", None)
|
|
771
|
+
if hub is None:
|
|
772
|
+
return "MCP is not available in this session."
|
|
773
|
+
name = arg.strip()
|
|
774
|
+
if name:
|
|
775
|
+
from rlmagent.mcp import McpError
|
|
776
|
+
|
|
777
|
+
action, _, server_name = name.partition(" ")
|
|
778
|
+
if action in ("login", "logout") and server_name.strip():
|
|
779
|
+
try:
|
|
780
|
+
operation = hub.login if action == "login" else hub.logout
|
|
781
|
+
return await operation(server_name.strip())
|
|
782
|
+
except McpError as exc:
|
|
783
|
+
return str(exc)
|
|
784
|
+
if name not in hub.servers:
|
|
785
|
+
return f"No MCP server named {name!r}."
|
|
786
|
+
try:
|
|
787
|
+
await hub.servers[name].start()
|
|
788
|
+
except McpError as exc:
|
|
789
|
+
return str(exc)
|
|
790
|
+
return hub.report()
|
|
791
|
+
|
|
792
|
+
def _cmd_image(self, arg: str) -> str:
|
|
793
|
+
"""Attach an image file to the next message, for models that can see images."""
|
|
794
|
+
from rlmagent.tools.files import _MAX_IMAGE_BYTES, _read_image
|
|
795
|
+
|
|
796
|
+
raw = arg.strip().strip('"')
|
|
797
|
+
if not raw:
|
|
798
|
+
count = len(getattr(self, "pending_images", []) or [])
|
|
799
|
+
return f"{count} image(s) wait for your next message. Usage: /image PATH | clear"
|
|
800
|
+
if raw == "clear":
|
|
801
|
+
self.pending_images = []
|
|
802
|
+
return "Dropped the attached images."
|
|
803
|
+
path = Path(raw).expanduser()
|
|
804
|
+
if not path.is_absolute():
|
|
805
|
+
path = Path(self.cwd or ".") / path
|
|
806
|
+
if not path.is_file():
|
|
807
|
+
return f"No file at {path}."
|
|
808
|
+
if path.suffix.lower() not in (".png", ".jpg", ".jpeg", ".gif", ".webp"):
|
|
809
|
+
return "Attach a PNG, JPEG, GIF or WebP image."
|
|
810
|
+
outcome = _read_image(path)
|
|
811
|
+
segment = outcome.content[0]
|
|
812
|
+
if getattr(segment, "type", "") != "image":
|
|
813
|
+
return segment.text
|
|
814
|
+
self.pending_images = [*(getattr(self, "pending_images", None) or []), segment]
|
|
815
|
+
size = path.stat().st_size / 1_000_000
|
|
816
|
+
return (f"Attached {path.name} ({size:.1f} MB, limit {_MAX_IMAGE_BYTES // 1_000_000} MB); "
|
|
817
|
+
"it goes with your next message.")
|
|
818
|
+
|
|
819
|
+
async def _cmd_btw(self, arg: str) -> str:
|
|
820
|
+
"""Answer from the conversation so far; the exchange is not kept in it."""
|
|
821
|
+
question = arg.strip()
|
|
822
|
+
thread = self.__dict__.setdefault("_btw_thread", [])
|
|
823
|
+
if question.lower() in ("clear", "new"):
|
|
824
|
+
thread.clear()
|
|
825
|
+
return "Side thread cleared."
|
|
826
|
+
if not question:
|
|
827
|
+
return "Usage: /btw QUESTION (follow-ups remember earlier side questions; /btw clear)"
|
|
828
|
+
recent = "\n".join(f"[{getattr(m, 'role', '?')}] {getattr(m, 'text', '')}"
|
|
829
|
+
for m in list(self.transcript)[-30:])[-60_000:]
|
|
830
|
+
earlier = "".join(f"\nSide question: {q}\nAnswer: {a}\n" for q, a in thread[-6:])
|
|
831
|
+
prompt = (f"Conversation so far:\n{recent}\n"
|
|
832
|
+
+ (f"\nEarlier side questions:{earlier}" if earlier else "")
|
|
833
|
+
+ f"\nSide question: {question}")
|
|
834
|
+
system = ("Answer the user's side question briefly from the conversation. It is not "
|
|
835
|
+
"part of the task; do not suggest running tools.")
|
|
836
|
+
try:
|
|
837
|
+
answer = (await self._utility_completion(prompt, system)).strip() or "(no answer)"
|
|
838
|
+
except Exception as exc: # noqa: BLE001 - shown to the user, the session goes on
|
|
839
|
+
return f"Could not answer: {exc}"
|
|
840
|
+
thread.append((question, answer))
|
|
841
|
+
del thread[:-6]
|
|
842
|
+
return answer
|
|
843
|
+
|
|
844
|
+
async def _cmd_update(self, arg: str) -> str:
|
|
845
|
+
"""Report the newest release; with ``apply``, install it in the background."""
|
|
846
|
+
import subprocess
|
|
847
|
+
import sys
|
|
848
|
+
|
|
849
|
+
from rlmagent.cli.main import get_version
|
|
850
|
+
from rlmagent.cli.update import select_release
|
|
851
|
+
from rlmagent.model.transport.client import fetch_json
|
|
852
|
+
|
|
853
|
+
installed = get_version()
|
|
854
|
+
if os.environ.get("RLM_AGENT_OFFLINE") == "1":
|
|
855
|
+
return f"rlm-agent {installed}. Update checks are off in offline mode."
|
|
856
|
+
try:
|
|
857
|
+
document = await asyncio.to_thread(
|
|
858
|
+
fetch_json, "https://pypi.org/pypi/rlmness-cli/json", timeout=15)
|
|
859
|
+
latest = select_release(document, "stable")
|
|
860
|
+
except Exception as exc: # noqa: BLE001 - reported, the session goes on
|
|
861
|
+
return f"Could not check for updates: {exc}"
|
|
862
|
+
if latest == installed:
|
|
863
|
+
return f"rlm-agent {installed} is the newest release."
|
|
864
|
+
if arg.strip().lower() != "apply":
|
|
865
|
+
return (f"rlm-agent {latest} is available (installed {installed}). "
|
|
866
|
+
"Run /update apply to install it, then restart.")
|
|
867
|
+
result = await asyncio.to_thread(
|
|
868
|
+
subprocess.run, [sys.executable, "-m", "rlmagent", "update", "--apply"],
|
|
869
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace", check=False)
|
|
870
|
+
tail = "\n".join((result.stdout + result.stderr).strip().splitlines()[-8:])
|
|
871
|
+
if result.returncode != 0:
|
|
872
|
+
return f"Update failed (exit {result.returncode}):\n{tail}"
|
|
873
|
+
return f"Installed rlm-agent {latest}. Restart rlm-agent to use it.\n{tail}"
|
|
874
|
+
|
|
875
|
+
async def _cmd_share(self, arg: str) -> str:
|
|
876
|
+
"""Preview the unlisted gist; publish only with the previewed SHA-256."""
|
|
877
|
+
from rlmagent.session_share import prepare, publish
|
|
878
|
+
|
|
879
|
+
words = arg.split()
|
|
880
|
+
body, digest = prepare(self.session_id, await self.export("html"),
|
|
881
|
+
await self.export("jsonl"))
|
|
882
|
+
if words[:1] != ["publish"]:
|
|
883
|
+
size = sum(len(f["content"]) for f in body["files"].values())
|
|
884
|
+
return (f"Unlisted gist preview for session {self.session_id}: "
|
|
885
|
+
f"{size:,} characters (session.html, session.jsonl). Nothing uploaded.\n"
|
|
886
|
+
f"SHA-256: {digest}\n"
|
|
887
|
+
"Anyone with the link can read it. To upload: /share publish <SHA-256>")
|
|
888
|
+
if len(words) != 2:
|
|
889
|
+
return "Use /share publish SHA256, with the digest the preview showed."
|
|
890
|
+
try:
|
|
891
|
+
url = await publish(body, expected_digest=words[1],
|
|
892
|
+
token=os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN"))
|
|
893
|
+
except ValueError as exc:
|
|
894
|
+
return str(exc)
|
|
895
|
+
return f"Shared: {url}"
|
|
896
|
+
|
|
897
|
+
def _cmd_logs(self, arg: str) -> str:
|
|
898
|
+
"""The last lines of this session's newest log file."""
|
|
899
|
+
from rlmagent.logs import log_root
|
|
900
|
+
|
|
901
|
+
try:
|
|
902
|
+
count = max(1, min(int(arg.strip() or 40), 400))
|
|
903
|
+
except ValueError:
|
|
904
|
+
return "Use /logs [LINES]."
|
|
905
|
+
folder = log_root(self.cwd) / str(self.session_id)
|
|
906
|
+
files = sorted((p for p in folder.glob("*") if p.is_file()),
|
|
907
|
+
key=lambda p: p.stat().st_mtime) if folder.is_dir() else []
|
|
908
|
+
if not files:
|
|
909
|
+
return f"No logs yet for this session (looked in {folder})."
|
|
910
|
+
newest = files[-1]
|
|
911
|
+
try:
|
|
912
|
+
lines = newest.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
913
|
+
except OSError as exc:
|
|
914
|
+
return f"Could not read {newest}: {exc}"
|
|
915
|
+
return f"{newest} (last {min(count, len(lines))} of {len(lines)} lines)\n" + "\n".join(lines[-count:])
|
|
916
|
+
|
|
917
|
+
async def _cmd_changelog(self, _arg: str) -> str:
|
|
918
|
+
"""Recent releases from the package index, newest first."""
|
|
919
|
+
from rlmagent.cli.main import get_version
|
|
920
|
+
from rlmagent.model.transport.client import fetch_json
|
|
921
|
+
|
|
922
|
+
if os.environ.get("RLM_AGENT_OFFLINE") == "1":
|
|
923
|
+
return f"rlm-agent {get_version()}. The release list needs the network."
|
|
924
|
+
try:
|
|
925
|
+
document = await asyncio.to_thread(
|
|
926
|
+
fetch_json, "https://pypi.org/pypi/rlmness-cli/json", timeout=15)
|
|
927
|
+
except Exception as exc: # noqa: BLE001
|
|
928
|
+
return f"Could not fetch releases: {exc}"
|
|
929
|
+
rows = []
|
|
930
|
+
for version, files in (document.get("releases") or {}).items():
|
|
931
|
+
uploaded = min((f.get("upload_time", "") for f in files or []), default="")
|
|
932
|
+
if uploaded:
|
|
933
|
+
rows.append((uploaded[:10], version))
|
|
934
|
+
rows.sort(reverse=True)
|
|
935
|
+
installed = get_version()
|
|
936
|
+
lines = [f"{day} {version}{' (installed)' if version == installed else ''}"
|
|
937
|
+
for day, version in rows[:10]]
|
|
938
|
+
return "Recent releases:\n" + ("\n".join(lines) or "none listed")
|
|
939
|
+
|
|
940
|
+
def _cmd_goal(self, arg: str) -> str:
|
|
941
|
+
pilot = getattr(self, "autopilot", None)
|
|
942
|
+
return pilot.command_goal(arg) if pilot is not None else "Goals are not available here."
|
|
943
|
+
|
|
944
|
+
def _cmd_autonomous(self, arg: str) -> str:
|
|
945
|
+
pilot = getattr(self, "autopilot", None)
|
|
946
|
+
if pilot is None:
|
|
947
|
+
return "Autonomous mode is not available here."
|
|
948
|
+
return pilot.command_autonomous(arg)
|
|
949
|
+
|
|
950
|
+
def _cmd_tasks(self, arg: str) -> str:
|
|
951
|
+
tasks = getattr(self, "tasks", None)
|
|
952
|
+
if tasks is None:
|
|
953
|
+
return "No task list in this session."
|
|
954
|
+
action, _, value = arg.strip().partition(" ")
|
|
955
|
+
try:
|
|
956
|
+
if action in ("", "list"):
|
|
957
|
+
return tasks.render()
|
|
958
|
+
if action == "add":
|
|
959
|
+
tasks.add(value)
|
|
960
|
+
elif action in ("done", "undo", "remove"):
|
|
961
|
+
getattr(tasks, action)(value.strip() or 0)
|
|
962
|
+
elif action == "clear":
|
|
963
|
+
tasks.clear()
|
|
964
|
+
else:
|
|
965
|
+
return "Usage: /tasks [add TEXT | done N | undo N | remove N | clear]"
|
|
966
|
+
except ValueError as exc:
|
|
967
|
+
return str(exc)
|
|
968
|
+
return tasks.render()
|
|
969
|
+
|
|
970
|
+
async def _cmd_undo(self, arg: str) -> str:
|
|
971
|
+
if not self._undo_stack:
|
|
972
|
+
ready = self.shadow is not None and self.shadow.available
|
|
973
|
+
return "Nothing to undo." if ready else self._why_no_undo("Undo")
|
|
974
|
+
if getattr(self, "_undo_problem", None):
|
|
975
|
+
return (f"The last turn has no snapshot ({self._undo_problem}), so undo would "
|
|
976
|
+
"go back further than you expect. Nothing was changed.")
|
|
977
|
+
arg = arg.strip()
|
|
978
|
+
if arg == "list":
|
|
979
|
+
rows = []
|
|
980
|
+
for steps, entry in enumerate(reversed(self._undo_stack), start=1):
|
|
981
|
+
rows.append(f" {steps}. {await asyncio.to_thread(self.shadow.label, entry[0])}")
|
|
982
|
+
return "Turns you can go back to (/undo N):\n" + "\n".join(rows)
|
|
983
|
+
if arg and not arg.isdigit():
|
|
984
|
+
return "Usage: /undo [N | list]"
|
|
985
|
+
steps = int(arg or 1)
|
|
986
|
+
if not 1 <= steps <= len(self._undo_stack):
|
|
987
|
+
return f"Only {len(self._undo_stack)} turns can be undone."
|
|
988
|
+
commit, tip, transcript, state = self._undo_stack[-steps]
|
|
989
|
+
try:
|
|
990
|
+
changed = await asyncio.to_thread(self.shadow.restore, commit)
|
|
991
|
+
except (RuntimeError, OSError, subprocess.TimeoutExpired) as exc:
|
|
992
|
+
return f"Undo failed; the conversation was not rewound. {exc}"
|
|
993
|
+
del self._undo_stack[-steps:]
|
|
994
|
+
self._save_undo()
|
|
995
|
+
if tip is not None and self._vault is not None:
|
|
996
|
+
await self.rewind(tip)
|
|
997
|
+
else:
|
|
998
|
+
self._harness.set_messages(transcript)
|
|
999
|
+
files = ", ".join(changed[:10]) + (" ..." if len(changed) > 10 else "")
|
|
1000
|
+
if state is not None:
|
|
1001
|
+
self.backend.restore_state(state)
|
|
1002
|
+
kernel = ("Saved variables were rolled back; effects outside the workspace were not."
|
|
1003
|
+
if state is not None else "Kernel variables are not rolled back.")
|
|
1004
|
+
skipped = sorted(self.shadow.skipped)
|
|
1005
|
+
missed = f" Not covered (over 5 MB): {', '.join(skipped[:5])}." if skipped else ""
|
|
1006
|
+
return (f"Undid {steps} turn{'s' if steps > 1 else ''}. Files restored: {files or 'none changed'}.{missed} {kernel}")
|
|
1007
|
+
|
|
1008
|
+
def set_provisional_title(self, text: str) -> None:
|
|
1009
|
+
"""Derive a placeholder title from *text* and publish it immediately."""
|
|
1010
|
+
title = summarize_prompt(text)
|
|
1011
|
+
if not title:
|
|
1012
|
+
return
|
|
1013
|
+
self._title = title
|
|
1014
|
+
self._sync_title_to_catalog(title)
|
|
1015
|
+
|
|
1016
|
+
def resume_run(self) -> AsyncIterator[AgentEvent]:
|
|
1017
|
+
"""Continue the agent loop without appending a new user message."""
|
|
1018
|
+
return self._run_wrapped(self._harness.resume())
|
|
1019
|
+
|
|
1020
|
+
def abort(self) -> None:
|
|
1021
|
+
"""Cancel the currently running agent loop."""
|
|
1022
|
+
refinement = getattr(self, "refinement", None)
|
|
1023
|
+
if refinement is not None:
|
|
1024
|
+
refinement.cancel()
|
|
1025
|
+
improver = getattr(self, "improver", None)
|
|
1026
|
+
if improver is not None:
|
|
1027
|
+
improver.drop_pending()
|
|
1028
|
+
backend = getattr(self, "backend", None)
|
|
1029
|
+
if backend is not None:
|
|
1030
|
+
backend.cancel()
|
|
1031
|
+
self._harness.abort()
|
|
1032
|
+
|
|
1033
|
+
async def shutdown(self) -> None:
|
|
1034
|
+
"""Flush pending state and release resources."""
|
|
1035
|
+
try:
|
|
1036
|
+
await self._shutdown()
|
|
1037
|
+
finally:
|
|
1038
|
+
lease = getattr(self, "_writer_lease", None)
|
|
1039
|
+
if lease is not None:
|
|
1040
|
+
lease.close()
|
|
1041
|
+
self._writer_lease = None
|
|
1042
|
+
|
|
1043
|
+
async def _shutdown(self) -> None:
|
|
1044
|
+
refinement = getattr(self, "refinement", None)
|
|
1045
|
+
if refinement is not None:
|
|
1046
|
+
await refinement.close()
|
|
1047
|
+
backend = getattr(self, "backend", None)
|
|
1048
|
+
if backend is not None:
|
|
1049
|
+
await backend.close()
|
|
1050
|
+
hub = getattr(self, "mcp", None)
|
|
1051
|
+
if hub is not None:
|
|
1052
|
+
await hub.close()
|
|
1053
|
+
jobs = getattr(self, "jobs", None)
|
|
1054
|
+
if jobs is not None:
|
|
1055
|
+
await jobs.close()
|
|
1056
|
+
remote = getattr(self, "remote_tools", None)
|
|
1057
|
+
if remote is not None:
|
|
1058
|
+
await remote.close()
|
|
1059
|
+
net = getattr(self, "agentnet", None)
|
|
1060
|
+
if net is not None:
|
|
1061
|
+
net.leave()
|
|
1062
|
+
self._unsubscribe()
|
|
1063
|
+
tree = getattr(self, "agent_tree", None)
|
|
1064
|
+
if tree is not None:
|
|
1065
|
+
# Every kernel in the tree, sub-agents' included, ends with the session.
|
|
1066
|
+
self.agent_tree = None
|
|
1067
|
+
await tree.close()
|
|
1068
|
+
if self._tip_id and self._pending_header is None:
|
|
1069
|
+
await self._append_record(
|
|
1070
|
+
TipRecord(entry_id=self._tip_id), advance_tip=False
|
|
1071
|
+
)
|
|
1072
|
+
|
|
1073
|
+
# ── mid-run queues ─────────────────────────────────────────────────
|
|
1074
|
+
|
|
1075
|
+
def inject(self, content: str) -> QueueShiftEvent:
|
|
1076
|
+
"""Queue a steering message for the active or next run."""
|
|
1077
|
+
return self._harness.inject(content)
|
|
1078
|
+
|
|
1079
|
+
def enqueue(self, content: str) -> QueueShiftEvent:
|
|
1080
|
+
"""Queue a follow-up message for when the active run stops."""
|
|
1081
|
+
return self._harness.enqueue(content)
|
|
1082
|
+
|
|
1083
|
+
@property
|
|
1084
|
+
def pending(self) -> PendingBatch:
|
|
1085
|
+
"""Snapshot of queued injection and follow-up messages."""
|
|
1086
|
+
return self._harness.pending
|
|
1087
|
+
|
|
1088
|
+
def flush_queues(self) -> PendingBatch:
|
|
1089
|
+
"""Clear and return all queued messages."""
|
|
1090
|
+
return self._harness.flush_queues()
|
|
1091
|
+
|
|
1092
|
+
# ── model / provider switching ─────────────────────────────────────
|
|
1093
|
+
|
|
1094
|
+
async def switch_model(self, model: str) -> None:
|
|
1095
|
+
"""Switch to a different model identifier (takes effect next turn)."""
|
|
1096
|
+
refinement = getattr(self, "refinement", None)
|
|
1097
|
+
if refinement is not None:
|
|
1098
|
+
refinement.cancel()
|
|
1099
|
+
self._model = model
|
|
1100
|
+
tree = getattr(self, "agent_tree", None)
|
|
1101
|
+
if tree is not None:
|
|
1102
|
+
tree.model = model
|
|
1103
|
+
if self._route.mode == "automatic":
|
|
1104
|
+
# A learned backend was learned for the previous model.
|
|
1105
|
+
self._route = RoutePin()
|
|
1106
|
+
self._sync_route_to_catalog()
|
|
1107
|
+
self._apply_route()
|
|
1108
|
+
await self._append_record(ModelSwapRecord(model=model))
|
|
1109
|
+
self._apply_model_preferences()
|
|
1110
|
+
|
|
1111
|
+
def _apply_model_preferences(self):
|
|
1112
|
+
from rlmagent.model.preferences import read
|
|
1113
|
+
|
|
1114
|
+
overrides = read(self._provider_name)["overrides"].get(self.model, {})
|
|
1115
|
+
setter = getattr(self._provider, "set_service_tier", None)
|
|
1116
|
+
if setter is not None and getattr(self._provider, "supports_service_tier", True):
|
|
1117
|
+
setter(overrides.get("tier", getattr(self._provider, "default_service_tier", None)))
|
|
1118
|
+
|
|
1119
|
+
async def switch_provider(
|
|
1120
|
+
self,
|
|
1121
|
+
provider: ModelProvider,
|
|
1122
|
+
provider_name: str,
|
|
1123
|
+
model: str | None = None,
|
|
1124
|
+
) -> None:
|
|
1125
|
+
"""Replace the active model provider (takes effect next turn)."""
|
|
1126
|
+
refinement = getattr(self, "refinement", None)
|
|
1127
|
+
if refinement is not None:
|
|
1128
|
+
refinement.cancel()
|
|
1129
|
+
tree = getattr(self, "agent_tree", None)
|
|
1130
|
+
if tree is not None:
|
|
1131
|
+
budgeted = tree.use_provider(provider, provider_name)
|
|
1132
|
+
provider = self._provider if getattr(self._provider, "follows_tree", False) else budgeted
|
|
1133
|
+
self._provider = provider
|
|
1134
|
+
self._provider_name = provider_name
|
|
1135
|
+
self._route = RoutePin()
|
|
1136
|
+
self._apply_route()
|
|
1137
|
+
self._sync_route_to_catalog()
|
|
1138
|
+
if self._catalog is not None:
|
|
1139
|
+
existing = self._catalog.get(self._session_id)
|
|
1140
|
+
if existing is not None:
|
|
1141
|
+
self._catalog.upsert(replace(
|
|
1142
|
+
existing, provider=provider_name, updated_at=time(),
|
|
1143
|
+
))
|
|
1144
|
+
# A fresh provider carries its own configured reasoning policy; the
|
|
1145
|
+
# session's chosen thinking level has to be re-applied on top.
|
|
1146
|
+
self._apply_thinking_level()
|
|
1147
|
+
self._harness.settings.provider = provider
|
|
1148
|
+
if model:
|
|
1149
|
+
await self.switch_model(model)
|
|
1150
|
+
|
|
1151
|
+
# ── thinking mode ──────────────────────────────────────────────────
|
|
1152
|
+
|
|
1153
|
+
async def set_thinking(self, level: str | None) -> None:
|
|
1154
|
+
"""Set the thinking/reasoning depth level (or disable with ``None``)."""
|
|
1155
|
+
self._thinking_level = level
|
|
1156
|
+
self._apply_thinking_level()
|
|
1157
|
+
await self._append_record(ReasoningLevelRecord(thinking_level=level))
|
|
1158
|
+
|
|
1159
|
+
def _apply_thinking_level(self) -> None:
|
|
1160
|
+
"""Push the active thinking level onto the provider."""
|
|
1161
|
+
setter = getattr(self._provider, "set_reasoning", None)
|
|
1162
|
+
if setter is None:
|
|
1163
|
+
return
|
|
1164
|
+
from rlmagent.reasoning import (
|
|
1165
|
+
normalize_thinking_level,
|
|
1166
|
+
thinking_to_budget,
|
|
1167
|
+
thinking_to_effort,
|
|
1168
|
+
)
|
|
1169
|
+
from rlmagent.model.settings import ReasoningPolicy
|
|
1170
|
+
|
|
1171
|
+
if self._thinking_level is None:
|
|
1172
|
+
setter(ReasoningPolicy(enabled=False))
|
|
1173
|
+
return
|
|
1174
|
+
level = normalize_thinking_level(self._thinking_level)
|
|
1175
|
+
setter(ReasoningPolicy(
|
|
1176
|
+
enabled=True,
|
|
1177
|
+
budget_tokens=thinking_to_budget(level),
|
|
1178
|
+
effort=thinking_to_effort(level),
|
|
1179
|
+
))
|
|
1180
|
+
|
|
1181
|
+
@property
|
|
1182
|
+
def thinking_level(self) -> str | None:
|
|
1183
|
+
"""The current thinking/reasoning depth, or ``None`` if disabled."""
|
|
1184
|
+
return self._thinking_level
|
|
1185
|
+
|
|
1186
|
+
# ── plan mode ──────────────────────────────────────────────────────
|
|
1187
|
+
|
|
1188
|
+
@property
|
|
1189
|
+
def plan_mode(self) -> bool:
|
|
1190
|
+
"""Whether the session is restricted to research-and-propose."""
|
|
1191
|
+
return self._plan_mode
|
|
1192
|
+
|
|
1193
|
+
def set_plan_mode(self, enabled: bool) -> None:
|
|
1194
|
+
"""Enter or leave plan mode (takes effect on the next turn)."""
|
|
1195
|
+
self._plan_mode = enabled
|
|
1196
|
+
self._apply_plan_mode()
|
|
1197
|
+
|
|
1198
|
+
def _apply_plan_mode(self) -> None:
|
|
1199
|
+
"""Push the tool set and system prompt matching the current mode."""
|
|
1200
|
+
from rlmagent.planning import plan_system_prompt, restrict_tools
|
|
1201
|
+
|
|
1202
|
+
if self._plan_mode:
|
|
1203
|
+
self._harness.settings.tools = restrict_tools(self._all_tools)
|
|
1204
|
+
self._harness.settings.system = plan_system_prompt(self._system)
|
|
1205
|
+
else:
|
|
1206
|
+
self._harness.settings.tools = list(self._all_tools)
|
|
1207
|
+
self._harness.settings.system = self._system
|
|
1208
|
+
|
|
1209
|
+
# ── context compaction ─────────────────────────────────────────────
|
|
1210
|
+
|
|
1211
|
+
async def compact(self) -> PruneSummaryEntry:
|
|
1212
|
+
limit = getattr(getattr(self, "policy", None), "max_compactions", None)
|
|
1213
|
+
done = getattr(self, "compactions", 0)
|
|
1214
|
+
if limit is not None and done >= limit:
|
|
1215
|
+
raise RuntimeError(f"this run allows {limit} compaction(s) and has used them")
|
|
1216
|
+
"""Compact the transcript by summarising older entries."""
|
|
1217
|
+
transcript = list(self._harness.transcript)
|
|
1218
|
+
plan = plan_compaction(transcript, self._record_ids, limits=_CONTEXT_LIMITS)
|
|
1219
|
+
if plan is None:
|
|
1220
|
+
raise ValueError(
|
|
1221
|
+
f"Transcript has {len(transcript)} entries; "
|
|
1222
|
+
f"need at least {_CONTEXT_LIMITS.min_entries} to compact."
|
|
1223
|
+
)
|
|
1224
|
+
|
|
1225
|
+
# A transcript that was compacted before carries its earlier summary
|
|
1226
|
+
# into this one, so repeated compactions merge rather than stack.
|
|
1227
|
+
system_prompt, user_prompt = build_summary_prompts(
|
|
1228
|
+
plan,
|
|
1229
|
+
system=_CONTEXT_LIMITS.summary_system,
|
|
1230
|
+
max_chars=_CONTEXT_LIMITS.max_summary_chars,
|
|
1231
|
+
)
|
|
1232
|
+
summary_text = ""
|
|
1233
|
+
cap = _CONTEXT_LIMITS.max_summary_chars
|
|
1234
|
+
prompt = user_prompt
|
|
1235
|
+
for attempt in range(3):
|
|
1236
|
+
try:
|
|
1237
|
+
candidate = await self._utility_completion(prompt, system_prompt)
|
|
1238
|
+
if not candidate.strip():
|
|
1239
|
+
raise ValueError("the summary response was empty")
|
|
1240
|
+
if len(candidate) > cap and attempt < 2:
|
|
1241
|
+
# Slicing would drop whatever came last, often the most
|
|
1242
|
+
# recent state. Ask again for a tighter summary instead.
|
|
1243
|
+
prompt = (
|
|
1244
|
+
f"{user_prompt}\n\nYour last summary ran to {len(candidate)} "
|
|
1245
|
+
f"characters; the limit is {cap}. Write it again, shorter, "
|
|
1246
|
+
"keeping the newest facts and open work."
|
|
1247
|
+
)
|
|
1248
|
+
continue
|
|
1249
|
+
except ValueError as error:
|
|
1250
|
+
if attempt == 2:
|
|
1251
|
+
raise ValueError("Compaction failed after 3 invalid summaries; history was retained") from error
|
|
1252
|
+
else:
|
|
1253
|
+
summary_text = candidate[:cap]
|
|
1254
|
+
break
|
|
1255
|
+
# The transcript shrinks but the kernel does not: say so, or the
|
|
1256
|
+
# model rebuilds variables it still has.
|
|
1257
|
+
if not summary_text.rstrip().endswith(COMPACTION_NOTE):
|
|
1258
|
+
summary_text = f"{summary_text.rstrip()}\n\n{COMPACTION_NOTE}"
|
|
1259
|
+
|
|
1260
|
+
prune_record = PruneRecord(
|
|
1261
|
+
summary=summary_text,
|
|
1262
|
+
replaces_entry_ids=list(plan.summarize_ids),
|
|
1263
|
+
)
|
|
1264
|
+
await self._append_record(prune_record)
|
|
1265
|
+
|
|
1266
|
+
result = apply_compaction(
|
|
1267
|
+
plan, summary_text, summary_record_id=prune_record.id
|
|
1268
|
+
)
|
|
1269
|
+
self._harness.set_messages(list(result.transcript))
|
|
1270
|
+
self.compactions = done + 1
|
|
1271
|
+
hook = getattr(self, "on_compacted", None)
|
|
1272
|
+
if hook is not None:
|
|
1273
|
+
hook()
|
|
1274
|
+
self._record_ids = list(result.record_ids)
|
|
1275
|
+
refinement = getattr(self, "refinement", None)
|
|
1276
|
+
if refinement is not None:
|
|
1277
|
+
refinement.compacted(transcript)
|
|
1278
|
+
|
|
1279
|
+
return result.summary_entry
|
|
1280
|
+
|
|
1281
|
+
def should_compact(self) -> bool:
|
|
1282
|
+
"""Return whether automatic compaction is recommended."""
|
|
1283
|
+
return exceeds_threshold(self.context_estimate())
|
|
1284
|
+
|
|
1285
|
+
def context_estimate(self) -> ContextEstimate:
|
|
1286
|
+
"""Full context accounting for the session as it stands."""
|
|
1287
|
+
return estimate_context(
|
|
1288
|
+
self._harness.transcript,
|
|
1289
|
+
system=self._system,
|
|
1290
|
+
tools=self._harness.settings.tools,
|
|
1291
|
+
limits=_CONTEXT_LIMITS,
|
|
1292
|
+
model=self._model,
|
|
1293
|
+
)
|
|
1294
|
+
|
|
1295
|
+
# ── catalog sync ───────────────────────────────────────────────────
|
|
1296
|
+
|
|
1297
|
+
@property
|
|
1298
|
+
def catalog(self) -> SessionCatalog | None:
|
|
1299
|
+
"""The session catalog, or ``None`` for ephemeral sessions."""
|
|
1300
|
+
return self._catalog
|
|
1301
|
+
|
|
1302
|
+
def _sync_title_to_catalog(self, title: str) -> None:
|
|
1303
|
+
"""Push a title change to the session catalog, if one is active."""
|
|
1304
|
+
if self._catalog is None:
|
|
1305
|
+
return
|
|
1306
|
+
existing = self._catalog.get(self._session_id)
|
|
1307
|
+
if existing is None:
|
|
1308
|
+
return
|
|
1309
|
+
self._catalog.upsert(replace(existing, title=title, updated_at=time()))
|
|
1310
|
+
|
|
1311
|
+
# ── inference routing ──────────────────────────────────────────────
|
|
1312
|
+
|
|
1313
|
+
@property
|
|
1314
|
+
def inference_route(self) -> RoutePin:
|
|
1315
|
+
"""The backend pin in effect for a routed provider."""
|
|
1316
|
+
return self._route
|
|
1317
|
+
|
|
1318
|
+
def set_inference_route(self, backend: str | None) -> None:
|
|
1319
|
+
"""Choose a backend (fixed) or hand the choice back to the router."""
|
|
1320
|
+
self._route = RoutePin("fixed", backend) if backend else RoutePin()
|
|
1321
|
+
self._apply_route()
|
|
1322
|
+
self._sync_route_to_catalog()
|
|
1323
|
+
|
|
1324
|
+
def _apply_route(self) -> None:
|
|
1325
|
+
"""Send the routed model id when this provider routes, else the plain one."""
|
|
1326
|
+
routed = self._provider_name == ROUTED_PROVIDER
|
|
1327
|
+
self._harness.settings.model = (
|
|
1328
|
+
self._route.model_for(self._model) if routed else self._model
|
|
1329
|
+
)
|
|
1330
|
+
|
|
1331
|
+
def _sync_route_to_catalog(self) -> None:
|
|
1332
|
+
if self._catalog is None:
|
|
1333
|
+
return
|
|
1334
|
+
existing = self._catalog.get(self._session_id)
|
|
1335
|
+
if existing is None:
|
|
1336
|
+
return
|
|
1337
|
+
self._catalog.upsert(replace(
|
|
1338
|
+
existing,
|
|
1339
|
+
inference_provider=self._route.backend,
|
|
1340
|
+
inference_provider_mode=self._route.mode,
|
|
1341
|
+
updated_at=time(),
|
|
1342
|
+
))
|
|
1343
|
+
|
|
1344
|
+
def _learn_route(self) -> None:
|
|
1345
|
+
"""Pin the backend that just answered, for an automatic route."""
|
|
1346
|
+
if self._provider_name != ROUTED_PROVIDER or self._route.backend:
|
|
1347
|
+
return
|
|
1348
|
+
if self._route.mode != "automatic":
|
|
1349
|
+
return
|
|
1350
|
+
served = getattr(self._provider, "served_by", None)
|
|
1351
|
+
if served:
|
|
1352
|
+
self._route = RoutePin("automatic", served)
|
|
1353
|
+
self._apply_route()
|
|
1354
|
+
self._sync_route_to_catalog()
|
|
1355
|
+
|
|
1356
|
+
def _should_reroute(self, reply: ModelEntry) -> bool:
|
|
1357
|
+
"""Whether a failed reply should retry on the router's own choice."""
|
|
1358
|
+
return (
|
|
1359
|
+
self._provider_name == ROUTED_PROVIDER
|
|
1360
|
+
and self._route.mode == "automatic"
|
|
1361
|
+
and self._route.backend is not None
|
|
1362
|
+
and reply.stop_reason == "error"
|
|
1363
|
+
and not reply.content
|
|
1364
|
+
and is_route_failure(getattr(self._provider, "last_failure_status", None))
|
|
1365
|
+
)
|
|
1366
|
+
|
|
1367
|
+
# ── slash-command dispatch ─────────────────────────────────────────
|
|
1368
|
+
|
|
1369
|
+
#: Registry commands this session offers. See ``_RUNNABLE_COMMANDS``.
|
|
1370
|
+
RUNNABLE_COMMANDS: frozenset[str] = _RUNNABLE_COMMANDS
|
|
1371
|
+
|
|
1372
|
+
#: Slash commands, with one-line help. Drives autocomplete and /help.
|
|
1373
|
+
#: Derived from the shared registry so the CLI, the TUI, and dispatch all
|
|
1374
|
+
#: read one list — a command cannot be completable but unrunnable, or
|
|
1375
|
+
#: runnable but undiscoverable.
|
|
1376
|
+
COMMANDS: tuple[tuple[str, str], ...] = _RUNNABLE_COMMAND_TABLE
|
|
1377
|
+
|
|
1378
|
+
async def handle_command(self, text: str) -> str | None:
|
|
1379
|
+
"""Handle a ``/``-prefixed command."""
|
|
1380
|
+
parsed = parse_command(text)
|
|
1381
|
+
if parsed is None:
|
|
1382
|
+
return None
|
|
1383
|
+
|
|
1384
|
+
name, arg = parsed
|
|
1385
|
+
# Resolve through the registry so aliases (/m, /fork, /title, /sh …)
|
|
1386
|
+
# reach the same handler as their primary name.
|
|
1387
|
+
command = COMMAND_REGISTRY.get(name)
|
|
1388
|
+
if command is None:
|
|
1389
|
+
return None
|
|
1390
|
+
|
|
1391
|
+
handler = self._command_handlers().get(command.name)
|
|
1392
|
+
if handler is None:
|
|
1393
|
+
# A registered command the session itself does not run (/quit,
|
|
1394
|
+
# /help, pickers). The frontend owns those; returning None lets it
|
|
1395
|
+
# decide rather than sending the text to the model as a prompt.
|
|
1396
|
+
return None
|
|
1397
|
+
|
|
1398
|
+
result = handler(arg)
|
|
1399
|
+
if asyncio.iscoroutine(result):
|
|
1400
|
+
return await result # type: ignore[misc]
|
|
1401
|
+
return result # type: ignore[return-value]
|
|
1402
|
+
|
|
1403
|
+
def _command_handlers(self) -> dict[str, Callable[[str], object]]:
|
|
1404
|
+
"""Map registry command names to this session's bound handlers."""
|
|
1405
|
+
return {
|
|
1406
|
+
"model": self._cmd_model,
|
|
1407
|
+
"provider": self._cmd_provider,
|
|
1408
|
+
"think": self._cmd_think,
|
|
1409
|
+
"tier": self._cmd_tier,
|
|
1410
|
+
"fast": self._cmd_fast,
|
|
1411
|
+
"plan": self._cmd_plan,
|
|
1412
|
+
"compact": self._cmd_compact,
|
|
1413
|
+
"improve": self._cmd_improve,
|
|
1414
|
+
"refine": self._cmd_refine,
|
|
1415
|
+
"tools": self._cmd_tools,
|
|
1416
|
+
"undo": self._cmd_undo,
|
|
1417
|
+
"diff": self._cmd_diff,
|
|
1418
|
+
"tasks": self._cmd_tasks,
|
|
1419
|
+
"mcp": self._cmd_mcp,
|
|
1420
|
+
"goal": self._cmd_goal,
|
|
1421
|
+
"btw": self._cmd_btw,
|
|
1422
|
+
"update": self._cmd_update,
|
|
1423
|
+
"share": self._cmd_share,
|
|
1424
|
+
"logs": self._cmd_logs,
|
|
1425
|
+
"changelog": self._cmd_changelog,
|
|
1426
|
+
"image": self._cmd_image,
|
|
1427
|
+
"autonomous": self._cmd_autonomous,
|
|
1428
|
+
"stats": self._cmd_stats,
|
|
1429
|
+
"name": self._cmd_name,
|
|
1430
|
+
"export": self._cmd_export,
|
|
1431
|
+
"branch": self._cmd_branch,
|
|
1432
|
+
"branches": self._cmd_branches,
|
|
1433
|
+
"rewind": self._cmd_rewind,
|
|
1434
|
+
"shell": self._cmd_shell,
|
|
1435
|
+
"skills": self._cmd_skills,
|
|
1436
|
+
"create-skill": self._cmd_create_skill,
|
|
1437
|
+
"reload": self._cmd_reload,
|
|
1438
|
+
"diag": self._cmd_diag,
|
|
1439
|
+
"system": self._cmd_system,
|
|
1440
|
+
"session": self._cmd_session,
|
|
1441
|
+
"help": self._cmd_help,
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
def _cmd_help(self, _arg: str) -> str:
|
|
1445
|
+
"""List every command available here, with usage and aliases."""
|
|
1446
|
+
lines = ["Commands:"]
|
|
1447
|
+
for command in COMMAND_REGISTRY.all_commands():
|
|
1448
|
+
if command.name not in _RUNNABLE_COMMANDS:
|
|
1449
|
+
continue
|
|
1450
|
+
aliases = (
|
|
1451
|
+
" (" + " ".join(f"/{a}" for a in command.aliases) + ")"
|
|
1452
|
+
if command.aliases
|
|
1453
|
+
else ""
|
|
1454
|
+
)
|
|
1455
|
+
lines.append(f" {command.display_usage:<28}{command.description}{aliases}")
|
|
1456
|
+
return "\n".join(lines)
|
|
1457
|
+
|
|
1458
|
+
# ── terminal command execution ─────────────────────────────────────
|
|
1459
|
+
|
|
1460
|
+
async def run_shell(
|
|
1461
|
+
self,
|
|
1462
|
+
command: str,
|
|
1463
|
+
*,
|
|
1464
|
+
inject: bool = False,
|
|
1465
|
+
timeout: float = 30.0,
|
|
1466
|
+
) -> ShellResultEntry:
|
|
1467
|
+
"""Execute a shell command and optionally inject output into context."""
|
|
1468
|
+
try:
|
|
1469
|
+
result = await asyncio.wait_for(
|
|
1470
|
+
asyncio.to_thread(
|
|
1471
|
+
subprocess.run,
|
|
1472
|
+
command,
|
|
1473
|
+
shell=True, # noqa: S602 — intentional user-controlled execution
|
|
1474
|
+
stdin=subprocess.DEVNULL,
|
|
1475
|
+
capture_output=True,
|
|
1476
|
+
text=True,
|
|
1477
|
+
encoding="utf-8",
|
|
1478
|
+
errors="replace",
|
|
1479
|
+
timeout=timeout,
|
|
1480
|
+
),
|
|
1481
|
+
timeout=timeout + 5,
|
|
1482
|
+
)
|
|
1483
|
+
output = result.stdout
|
|
1484
|
+
if result.stderr:
|
|
1485
|
+
output += ("\n" if output else "") + result.stderr
|
|
1486
|
+
entry = ShellResultEntry(
|
|
1487
|
+
command=command,
|
|
1488
|
+
output=output,
|
|
1489
|
+
exit_code=result.returncode,
|
|
1490
|
+
)
|
|
1491
|
+
except (TimeoutError, subprocess.TimeoutExpired):
|
|
1492
|
+
entry = ShellResultEntry(
|
|
1493
|
+
command=command,
|
|
1494
|
+
output="Command timed out",
|
|
1495
|
+
exit_code=None,
|
|
1496
|
+
cancelled=True,
|
|
1497
|
+
)
|
|
1498
|
+
|
|
1499
|
+
if inject:
|
|
1500
|
+
self._harness.push_message(entry)
|
|
1501
|
+
await self._persist_entry(entry)
|
|
1502
|
+
return entry
|
|
1503
|
+
|
|
1504
|
+
# ── session tree: branching and rewinding ───────────────────────────
|
|
1505
|
+
|
|
1506
|
+
async def tree_choices(self) -> tuple[SessionTreeChoice, ...]:
|
|
1507
|
+
"""Return branchable session entries for a tree picker."""
|
|
1508
|
+
if self._vault is None:
|
|
1509
|
+
return ()
|
|
1510
|
+
records = await self._vault.read_all()
|
|
1511
|
+
indents = _tree_branch_indents(records)
|
|
1512
|
+
return tuple(
|
|
1513
|
+
SessionTreeChoice(
|
|
1514
|
+
entry_id=record.id,
|
|
1515
|
+
label=_tree_choice_label(
|
|
1516
|
+
record, branch_indent=indents.get(record.id, 0)
|
|
1517
|
+
),
|
|
1518
|
+
active=record.id == self._tip_id,
|
|
1519
|
+
is_tool_call=_is_tool_call_record(record),
|
|
1520
|
+
)
|
|
1521
|
+
for record in _ordered_tree_records(records)
|
|
1522
|
+
if _is_branchable_record(record)
|
|
1523
|
+
)
|
|
1524
|
+
|
|
1525
|
+
async def branch(self, summary: str | None = None) -> str:
|
|
1526
|
+
"""Create a conversation branch from the current point."""
|
|
1527
|
+
refinement = getattr(self, "refinement", None)
|
|
1528
|
+
if refinement is not None:
|
|
1529
|
+
refinement.cancel()
|
|
1530
|
+
if summary is None:
|
|
1531
|
+
context = "\n".join(
|
|
1532
|
+
f"[{e.role}] {surface_text(e)}"
|
|
1533
|
+
for e in self._harness.transcript[-4:]
|
|
1534
|
+
)
|
|
1535
|
+
summary = await self._utility_completion(
|
|
1536
|
+
f"Summarize this conversation branch concisely:\n{context}",
|
|
1537
|
+
"Produce a 1-2 sentence summary of the conversation so far.",
|
|
1538
|
+
)
|
|
1539
|
+
|
|
1540
|
+
record = ForkSummaryRecord(
|
|
1541
|
+
summary=summary.strip(),
|
|
1542
|
+
branch_root_id=self._tip_id,
|
|
1543
|
+
)
|
|
1544
|
+
await self._append_record(record)
|
|
1545
|
+
return record.id
|
|
1546
|
+
|
|
1547
|
+
async def rewind(self, entry_id: str) -> None:
|
|
1548
|
+
"""Rewind the session to a previous point in the conversation tree."""
|
|
1549
|
+
refinement = getattr(self, "refinement", None)
|
|
1550
|
+
if refinement is not None:
|
|
1551
|
+
refinement.cancel()
|
|
1552
|
+
if self._vault is None:
|
|
1553
|
+
raise RuntimeError("Rewind requires a persistent session.")
|
|
1554
|
+
|
|
1555
|
+
records = await self._vault.read_all()
|
|
1556
|
+
path = trace_to_entry(records, entry_id)
|
|
1557
|
+
state = project_records(path)
|
|
1558
|
+
|
|
1559
|
+
self._harness.set_messages(state.transcript)
|
|
1560
|
+
self._record_ids = state.record_ids
|
|
1561
|
+
self._tip_id = entry_id
|
|
1562
|
+
# The active model stays: an older point may predate a model or
|
|
1563
|
+
# provider switch, and its model would not fit the current provider.
|
|
1564
|
+
if state.thinking_level is not None:
|
|
1565
|
+
self._thinking_level = state.thinking_level
|
|
1566
|
+
if state.title:
|
|
1567
|
+
self._title = state.title
|
|
1568
|
+
|
|
1569
|
+
await self._append_record(
|
|
1570
|
+
TipRecord(entry_id=entry_id), advance_tip=False
|
|
1571
|
+
)
|
|
1572
|
+
await self._mend_stored_history()
|
|
1573
|
+
|
|
1574
|
+
if refinement is not None:
|
|
1575
|
+
refinement.at_boundary()
|
|
1576
|
+
|
|
1577
|
+
async def _mend_stored_history(self) -> HistoryMend | None:
|
|
1578
|
+
"""Repair malformed tool-call pairing in the active branch, durably."""
|
|
1579
|
+
transcript = list(self._harness.transcript)
|
|
1580
|
+
mend = mend_tool_history(transcript)
|
|
1581
|
+
if not mend.changed:
|
|
1582
|
+
return None
|
|
1583
|
+
self._harness.set_messages(list(mend.entries))
|
|
1584
|
+
if self._vault is None:
|
|
1585
|
+
return mend
|
|
1586
|
+
|
|
1587
|
+
records = await self._vault.read_all()
|
|
1588
|
+
parent_id, fresh = self._repair_branch_point(records, transcript, mend.entries)
|
|
1589
|
+
dropped = _records_after(records, self._tip_id, parent_id)
|
|
1590
|
+
|
|
1591
|
+
staged: list[SessionRecord] = [
|
|
1592
|
+
ExtensionRecord(
|
|
1593
|
+
parent_id=parent_id,
|
|
1594
|
+
namespace=_HISTORY_REPAIR_NAMESPACE,
|
|
1595
|
+
data={"version": 1, **mend.counters()},
|
|
1596
|
+
)
|
|
1597
|
+
]
|
|
1598
|
+
staged += [TranscriptRecord(message=entry) for entry in fresh]
|
|
1599
|
+
staged.append(ModelSwapRecord(model=self._model))
|
|
1600
|
+
staged.append(ReasoningLevelRecord(thinking_level=self._thinking_level))
|
|
1601
|
+
if self._title:
|
|
1602
|
+
staged.append(TagRecord(label=self._title))
|
|
1603
|
+
# Application records that sat on the abandoned stretch travel along.
|
|
1604
|
+
staged += [
|
|
1605
|
+
ExtensionRecord(namespace=r.namespace, data=r.data)
|
|
1606
|
+
for r in dropped
|
|
1607
|
+
if isinstance(r, ExtensionRecord) and r.namespace != _HISTORY_REPAIR_NAMESPACE
|
|
1608
|
+
]
|
|
1609
|
+
for previous, record in zip(staged, staged[1:], strict=False):
|
|
1610
|
+
record.parent_id = previous.id
|
|
1611
|
+
for record in staged:
|
|
1612
|
+
await self._vault.append(record)
|
|
1613
|
+
self._tip_id = staged[-1].id
|
|
1614
|
+
await self._append_record(TipRecord(entry_id=self._tip_id), advance_tip=False)
|
|
1615
|
+
|
|
1616
|
+
state = project_records(trace_to_entry([*records, *staged], self._tip_id))
|
|
1617
|
+
self._record_ids = state.record_ids
|
|
1618
|
+
return mend
|
|
1619
|
+
|
|
1620
|
+
@staticmethod
|
|
1621
|
+
def _repair_branch_point(
|
|
1622
|
+
records: list[SessionRecord],
|
|
1623
|
+
original: list[TranscriptEntry],
|
|
1624
|
+
repaired: tuple[TranscriptEntry, ...],
|
|
1625
|
+
) -> tuple[str | None, list[TranscriptEntry]]:
|
|
1626
|
+
"""Where the repair branch attaches, and which messages it must carry."""
|
|
1627
|
+
prefix = 0
|
|
1628
|
+
for before, after in zip(original, repaired, strict=False):
|
|
1629
|
+
if before is not after:
|
|
1630
|
+
break
|
|
1631
|
+
prefix += 1
|
|
1632
|
+
by_id = {record.id: record for record in records}
|
|
1633
|
+
state = project_active_branch(records)
|
|
1634
|
+
if prefix and len(state.record_ids) >= prefix:
|
|
1635
|
+
candidate = state.record_ids[prefix - 1]
|
|
1636
|
+
if candidate in by_id:
|
|
1637
|
+
try:
|
|
1638
|
+
projected = project_records(trace_to_entry(records, candidate)).transcript
|
|
1639
|
+
except TreeIntegrityError:
|
|
1640
|
+
projected = []
|
|
1641
|
+
if projected == original[:prefix]:
|
|
1642
|
+
return candidate, list(repaired[prefix:])
|
|
1643
|
+
header = next((r for r in records if isinstance(r, SessionMetaRecord)), None)
|
|
1644
|
+
return (header.id if header else None), list(repaired)
|
|
1645
|
+
|
|
1646
|
+
# ── multi-session lifecycle ────────────────────────────────────────
|
|
1647
|
+
|
|
1648
|
+
async def new_session(
|
|
1649
|
+
self,
|
|
1650
|
+
sessions_dir: Path | None = None,
|
|
1651
|
+
) -> CodingSession:
|
|
1652
|
+
"""Create a fresh session, preserving the current provider/model/tools."""
|
|
1653
|
+
leased = getattr(self, "_writer_lease", None) is not None
|
|
1654
|
+
await self.shutdown()
|
|
1655
|
+
base = sessions_dir
|
|
1656
|
+
if base is None and self._vault:
|
|
1657
|
+
base = self._vault.path.parent
|
|
1658
|
+
replacement = await CodingSession.create(
|
|
1659
|
+
provider=self._provider,
|
|
1660
|
+
provider_name=self._provider_name,
|
|
1661
|
+
model=self._model,
|
|
1662
|
+
system=self._system,
|
|
1663
|
+
tools=list(self._all_tools),
|
|
1664
|
+
sessions_dir=base,
|
|
1665
|
+
tools_loader=self._tools_loader,
|
|
1666
|
+
skills_loader=self._skills_loader,
|
|
1667
|
+
templates_loader=self._templates_loader,
|
|
1668
|
+
)
|
|
1669
|
+
if leased and base is not None:
|
|
1670
|
+
from rlmagent.harness.session.lease import acquire
|
|
1671
|
+
|
|
1672
|
+
replacement._writer_lease = acquire(base, replacement.session_id)
|
|
1673
|
+
refinement = getattr(self, 'refinement', None)
|
|
1674
|
+
if refinement is not None:
|
|
1675
|
+
refinement.transfer(replacement)
|
|
1676
|
+
return replacement
|
|
1677
|
+
|
|
1678
|
+
async def replace_session(self, session_id: str) -> CodingSession:
|
|
1679
|
+
"""Switch to a different existing session."""
|
|
1680
|
+
if self._vault is None:
|
|
1681
|
+
raise RuntimeError("Replace requires a persistent session.")
|
|
1682
|
+
if session_id == self.session_id:
|
|
1683
|
+
return self
|
|
1684
|
+
lease = None
|
|
1685
|
+
base = self._vault.path.parent
|
|
1686
|
+
if getattr(self, "_writer_lease", None) is not None:
|
|
1687
|
+
from rlmagent.harness.session.lease import acquire
|
|
1688
|
+
|
|
1689
|
+
lease = acquire(base, session_id)
|
|
1690
|
+
try:
|
|
1691
|
+
await self.shutdown()
|
|
1692
|
+
replacement = await CodingSession.resume(
|
|
1693
|
+
session_id,
|
|
1694
|
+
provider=self._provider,
|
|
1695
|
+
provider_name=self._provider_name,
|
|
1696
|
+
model=self._model,
|
|
1697
|
+
system=self._system,
|
|
1698
|
+
tools=list(self._all_tools),
|
|
1699
|
+
sessions_dir=base,
|
|
1700
|
+
tools_loader=self._tools_loader,
|
|
1701
|
+
skills_loader=self._skills_loader,
|
|
1702
|
+
templates_loader=self._templates_loader,
|
|
1703
|
+
)
|
|
1704
|
+
except BaseException:
|
|
1705
|
+
if lease is not None:
|
|
1706
|
+
lease.close()
|
|
1707
|
+
raise
|
|
1708
|
+
replacement._writer_lease = lease
|
|
1709
|
+
refinement = getattr(self, 'refinement', None)
|
|
1710
|
+
if refinement is not None:
|
|
1711
|
+
refinement.transfer(replacement)
|
|
1712
|
+
return replacement
|
|
1713
|
+
|
|
1714
|
+
# ── extensions and hot reload ──────────────────────────────────────
|
|
1715
|
+
|
|
1716
|
+
def load_extensions(self) -> list[object]:
|
|
1717
|
+
"""Discover and activate extensions from entry points."""
|
|
1718
|
+
loaded: list[object] = []
|
|
1719
|
+
try:
|
|
1720
|
+
eps = importlib.metadata.entry_points(group=_EXTENSION_GROUP)
|
|
1721
|
+
except TypeError:
|
|
1722
|
+
eps = importlib.metadata.entry_points().get( # type: ignore[assignment]
|
|
1723
|
+
_EXTENSION_GROUP, []
|
|
1724
|
+
)
|
|
1725
|
+
for ep in eps:
|
|
1726
|
+
try:
|
|
1727
|
+
ext = ep.load()()
|
|
1728
|
+
if ext is not None:
|
|
1729
|
+
loaded.append(ext)
|
|
1730
|
+
except Exception: # noqa: BLE001
|
|
1731
|
+
pass
|
|
1732
|
+
self._extensions = loaded
|
|
1733
|
+
return loaded
|
|
1734
|
+
|
|
1735
|
+
async def reload(self) -> None:
|
|
1736
|
+
"""Hot-reload tools, skills, extensions, and system prompt."""
|
|
1737
|
+
self._extensions = self.load_extensions()
|
|
1738
|
+
if self._tools_loader:
|
|
1739
|
+
self._all_tools = self._tools_loader()
|
|
1740
|
+
self.reload_skills()
|
|
1741
|
+
self._apply_plan_mode()
|
|
1742
|
+
|
|
1743
|
+
# ── skills ─────────────────────────────────────────────────────────
|
|
1744
|
+
|
|
1745
|
+
@property
|
|
1746
|
+
def skills(self) -> tuple[Any, ...]:
|
|
1747
|
+
"""Skills currently available to this session."""
|
|
1748
|
+
return tuple(self._skills)
|
|
1749
|
+
|
|
1750
|
+
def get_skill(self, name: str) -> Any | None:
|
|
1751
|
+
"""Look up a loaded skill by name, case-insensitively."""
|
|
1752
|
+
wanted = name.strip().lstrip("/").removeprefix("skill:").lower()
|
|
1753
|
+
for skill in self._skills:
|
|
1754
|
+
if skill.name.lower() == wanted:
|
|
1755
|
+
return skill
|
|
1756
|
+
return None
|
|
1757
|
+
|
|
1758
|
+
def reload_skills(self) -> int:
|
|
1759
|
+
"""Re-read skills from disk, returning how many are now loaded."""
|
|
1760
|
+
if self._skills_loader is not None:
|
|
1761
|
+
self._skills = list(self._skills_loader())
|
|
1762
|
+
refinement = getattr(self, "refinement", None)
|
|
1763
|
+
if refinement is not None:
|
|
1764
|
+
refinement.installed = list(self._skills)
|
|
1765
|
+
refinement._apply_active()
|
|
1766
|
+
return len(self._skills)
|
|
1767
|
+
|
|
1768
|
+
def expand_skill(self, text: str) -> str | None:
|
|
1769
|
+
"""Expand ``/skill:<name> [args]`` into an injectable prompt block."""
|
|
1770
|
+
from rlmagent.skillset import expand_command
|
|
1771
|
+
|
|
1772
|
+
index = {s.name: s for s in self._skills}
|
|
1773
|
+
try:
|
|
1774
|
+
return expand_command(text, index)
|
|
1775
|
+
except KeyError:
|
|
1776
|
+
return None
|
|
1777
|
+
|
|
1778
|
+
# ── prompt templates ───────────────────────────────────────────────
|
|
1779
|
+
|
|
1780
|
+
@property
|
|
1781
|
+
def prompt_templates(self) -> tuple[Any, ...]:
|
|
1782
|
+
"""Markdown prompt templates available to this session."""
|
|
1783
|
+
return tuple(self._templates)
|
|
1784
|
+
|
|
1785
|
+
def reload_prompt_templates(self) -> int:
|
|
1786
|
+
"""Re-read prompt templates from disk, returning how many are loaded."""
|
|
1787
|
+
if self._templates_loader is not None:
|
|
1788
|
+
self._templates = list(self._templates_loader())
|
|
1789
|
+
return len(self._templates)
|
|
1790
|
+
|
|
1791
|
+
def expand_template(self, text: str) -> str | None:
|
|
1792
|
+
"""Expand ``/<template-name> [args]`` into its rendered body."""
|
|
1793
|
+
from rlmagent.prompts import expand_slash_command
|
|
1794
|
+
|
|
1795
|
+
index = {t.name: t for t in self._templates}
|
|
1796
|
+
if not index:
|
|
1797
|
+
return None
|
|
1798
|
+
try:
|
|
1799
|
+
return expand_slash_command(text, index)
|
|
1800
|
+
except KeyError:
|
|
1801
|
+
return None
|
|
1802
|
+
|
|
1803
|
+
# ── exports ────────────────────────────────────────────────────────
|
|
1804
|
+
|
|
1805
|
+
async def export(self, fmt: str = "text") -> str:
|
|
1806
|
+
"""Export the session transcript in the given format (text/json/jsonl)."""
|
|
1807
|
+
if fmt == "html":
|
|
1808
|
+
from rlmagent.session_html import render
|
|
1809
|
+
|
|
1810
|
+
messages = list(self.transcript)
|
|
1811
|
+
records = None
|
|
1812
|
+
if self._vault is not None:
|
|
1813
|
+
records = await self._vault.read_all()
|
|
1814
|
+
return render(messages, self.title or self.session_id, records=records)
|
|
1815
|
+
if self._vault is not None:
|
|
1816
|
+
return await self._export_from_vault(fmt)
|
|
1817
|
+
return self._export_from_transcript(fmt)
|
|
1818
|
+
|
|
1819
|
+
async def _export_from_vault(self, fmt: str) -> str:
|
|
1820
|
+
assert self._vault is not None
|
|
1821
|
+
records = await self._vault.read_all()
|
|
1822
|
+
if fmt == "jsonl":
|
|
1823
|
+
from rlmagent.harness.session.store import serialize_record
|
|
1824
|
+
|
|
1825
|
+
return "".join(serialize_record(r) for r in records)
|
|
1826
|
+
if fmt == "json":
|
|
1827
|
+
from pydantic import TypeAdapter
|
|
1828
|
+
|
|
1829
|
+
adapter: TypeAdapter[list[SessionRecord]] = TypeAdapter(list[SessionRecord])
|
|
1830
|
+
return adapter.dump_json(records, indent=2).decode()
|
|
1831
|
+
lines: list[str] = []
|
|
1832
|
+
for r in records:
|
|
1833
|
+
if isinstance(r, TranscriptRecord):
|
|
1834
|
+
lines.append(f"[{r.message.role}] {surface_text(r.message)}")
|
|
1835
|
+
return "\n\n".join(lines)
|
|
1836
|
+
|
|
1837
|
+
def _export_from_transcript(self, fmt: str) -> str:
|
|
1838
|
+
transcript = self._harness.transcript
|
|
1839
|
+
if fmt == "json":
|
|
1840
|
+
from pydantic import TypeAdapter
|
|
1841
|
+
|
|
1842
|
+
adapter: TypeAdapter[list[TranscriptEntry]] = TypeAdapter(list[TranscriptEntry])
|
|
1843
|
+
return adapter.dump_json(list(transcript), indent=2).decode()
|
|
1844
|
+
return "\n\n".join(
|
|
1845
|
+
f"[{e.role}] {surface_text(e)}" for e in transcript
|
|
1846
|
+
)
|
|
1847
|
+
|
|
1848
|
+
# ── diagnostics ────────────────────────────────────────────────────
|
|
1849
|
+
|
|
1850
|
+
def diagnostics(self) -> dict[str, JValue]:
|
|
1851
|
+
"""Return a snapshot of session state for debugging."""
|
|
1852
|
+
return {
|
|
1853
|
+
"session_id": self._session_id,
|
|
1854
|
+
"model": self._model,
|
|
1855
|
+
"provider": self._provider_name,
|
|
1856
|
+
"thinking_level": self._thinking_level,
|
|
1857
|
+
"title": self._title,
|
|
1858
|
+
"message_count": len(self._harness.transcript),
|
|
1859
|
+
"turn_count": self._turn_count,
|
|
1860
|
+
"total_input_tokens": self._total_input,
|
|
1861
|
+
"total_output_tokens": self._total_output,
|
|
1862
|
+
"total_cost_usd": self._total_cost,
|
|
1863
|
+
"estimated_context_tokens": self._estimate_context_tokens(),
|
|
1864
|
+
"context_limit": self._context_limit(),
|
|
1865
|
+
"active": self._harness.active,
|
|
1866
|
+
"pending_count": self._harness.pending_count,
|
|
1867
|
+
"persistent": self._vault is not None,
|
|
1868
|
+
"cwd": self._cwd,
|
|
1869
|
+
"extensions_loaded": len(self._extensions),
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
# ── read-only API ──────────────────────────────────────────────────
|
|
1873
|
+
|
|
1874
|
+
@property
|
|
1875
|
+
def session_id(self) -> str:
|
|
1876
|
+
"""Unique identifier for this session."""
|
|
1877
|
+
return self._session_id
|
|
1878
|
+
|
|
1879
|
+
@property
|
|
1880
|
+
def transcript(self) -> tuple[TranscriptEntry, ...]:
|
|
1881
|
+
"""Immutable snapshot of the current conversation."""
|
|
1882
|
+
return self._harness.transcript
|
|
1883
|
+
|
|
1884
|
+
@property
|
|
1885
|
+
def model(self) -> str:
|
|
1886
|
+
"""The active model identifier."""
|
|
1887
|
+
return self._model
|
|
1888
|
+
|
|
1889
|
+
@property
|
|
1890
|
+
def provider_name(self) -> str:
|
|
1891
|
+
"""The active provider name."""
|
|
1892
|
+
return self._provider_name
|
|
1893
|
+
|
|
1894
|
+
@property
|
|
1895
|
+
def tools(self) -> tuple[ToolSpec, ...]:
|
|
1896
|
+
"""The currently registered tool specifications."""
|
|
1897
|
+
return tuple(self._harness.settings.tools)
|
|
1898
|
+
|
|
1899
|
+
@property
|
|
1900
|
+
def active(self) -> bool:
|
|
1901
|
+
"""Whether an agent run is in progress."""
|
|
1902
|
+
return self._harness.active
|
|
1903
|
+
|
|
1904
|
+
@property
|
|
1905
|
+
def title(self) -> str | None:
|
|
1906
|
+
"""The session display name, if set."""
|
|
1907
|
+
return self._title
|
|
1908
|
+
|
|
1909
|
+
@property
|
|
1910
|
+
def cwd(self) -> str:
|
|
1911
|
+
"""Working directory associated with this session."""
|
|
1912
|
+
return self._cwd
|
|
1913
|
+
|
|
1914
|
+
@property
|
|
1915
|
+
def usage(self) -> SessionStats:
|
|
1916
|
+
"""Cumulative usage statistics."""
|
|
1917
|
+
return SessionStats(
|
|
1918
|
+
turn_count=self._turn_count,
|
|
1919
|
+
message_count=len(self._harness.transcript),
|
|
1920
|
+
total_input_tokens=self._total_input,
|
|
1921
|
+
total_output_tokens=self._total_output,
|
|
1922
|
+
total_cost_usd=self._total_cost,
|
|
1923
|
+
estimated_context_tokens=self._estimate_context_tokens(),
|
|
1924
|
+
context_limit=self._context_limit(),
|
|
1925
|
+
cache_read_tokens=self._cache_read,
|
|
1926
|
+
cache_write_tokens=self._cache_write,
|
|
1927
|
+
latest_prompt_tokens=self._latest_prompt,
|
|
1928
|
+
latest_cache_read_tokens=self._latest_cache_read,
|
|
1929
|
+
)
|
|
1930
|
+
|
|
1931
|
+
@property
|
|
1932
|
+
def harness(self) -> RuntimeHarness:
|
|
1933
|
+
"""Direct access to the underlying harness (advanced use)."""
|
|
1934
|
+
return self._harness
|
|
1935
|
+
|
|
1936
|
+
# ── internal: event handling ────────────────────────────────────────
|
|
1937
|
+
|
|
1938
|
+
async def _on_harness_event(self, event: AgentEvent) -> None:
|
|
1939
|
+
"""Subscriber called by the harness for every streamed event."""
|
|
1940
|
+
if not isinstance(event, MessageEndEvent):
|
|
1941
|
+
return
|
|
1942
|
+
record_id = await self._persist_entry(event.message)
|
|
1943
|
+
if record_id:
|
|
1944
|
+
self._record_ids.append(record_id)
|
|
1945
|
+
if isinstance(event.message, ModelEntry):
|
|
1946
|
+
self._tally(event.message)
|
|
1947
|
+
|
|
1948
|
+
def _tally(self, reply: ModelEntry) -> None:
|
|
1949
|
+
"""Fold one model reply's usage into the session totals."""
|
|
1950
|
+
usage = reply.usage
|
|
1951
|
+
prompt = usage.input + usage.cache_read + usage.cache_write
|
|
1952
|
+
self._turn_count += 1
|
|
1953
|
+
self._total_input += prompt
|
|
1954
|
+
self._total_output += usage.output
|
|
1955
|
+
self._total_cost += usage.cost.total
|
|
1956
|
+
self._cache_read += usage.cache_read
|
|
1957
|
+
self._cache_write += usage.cache_write
|
|
1958
|
+
self._latest_prompt = prompt
|
|
1959
|
+
self._latest_cache_read = usage.cache_read
|
|
1960
|
+
|
|
1961
|
+
async def _run_wrapped(
|
|
1962
|
+
self, stream: AsyncIterator[AgentEvent]
|
|
1963
|
+
) -> AsyncIterator[AgentEvent]:
|
|
1964
|
+
"""Wrap a harness event stream with post-run processing."""
|
|
1965
|
+
completed = False
|
|
1966
|
+
used: set[str] = set()
|
|
1967
|
+
backend_events = getattr(getattr(self, "backend", None), "events", ())
|
|
1968
|
+
first_event = len(backend_events)
|
|
1969
|
+
try:
|
|
1970
|
+
while True:
|
|
1971
|
+
recovery: str | None = None
|
|
1972
|
+
# Events for the failed reply a recovery replaces. A reply's
|
|
1973
|
+
# start is held until its outcome is known, so a recovered
|
|
1974
|
+
# failure never reaches the caller as an error.
|
|
1975
|
+
withheld: list[AgentEvent] = []
|
|
1976
|
+
async for event in stream:
|
|
1977
|
+
if recovery is not None:
|
|
1978
|
+
# The rest of a run being recovered is not shown.
|
|
1979
|
+
withheld.append(event)
|
|
1980
|
+
continue
|
|
1981
|
+
if isinstance(event, MessageStartEvent) and isinstance(
|
|
1982
|
+
event.message, ModelEntry
|
|
1983
|
+
):
|
|
1984
|
+
withheld = [event]
|
|
1985
|
+
continue
|
|
1986
|
+
if isinstance(event, MessageEndEvent) and isinstance(
|
|
1987
|
+
event.message, ModelEntry
|
|
1988
|
+
):
|
|
1989
|
+
reply = event.message
|
|
1990
|
+
if "overflow" not in used and _is_context_overflow(reply):
|
|
1991
|
+
recovery = "overflow"
|
|
1992
|
+
elif "reroute" not in used and self._should_reroute(reply):
|
|
1993
|
+
recovery = "reroute"
|
|
1994
|
+
elif reply.stop_reason not in ("error", "aborted"):
|
|
1995
|
+
self._learn_route()
|
|
1996
|
+
if recovery is not None:
|
|
1997
|
+
withheld.append(event)
|
|
1998
|
+
continue
|
|
1999
|
+
for early in withheld:
|
|
2000
|
+
yield early
|
|
2001
|
+
withheld = []
|
|
2002
|
+
if isinstance(event, RunEndEvent):
|
|
2003
|
+
completed = True
|
|
2004
|
+
pilot = getattr(self, "autopilot", None)
|
|
2005
|
+
if pilot is not None and pilot.autonomous:
|
|
2006
|
+
pilot.observe_turn(event.messages, backend_events[first_event:])
|
|
2007
|
+
yield event
|
|
2008
|
+
if recovery is None:
|
|
2009
|
+
for early in withheld:
|
|
2010
|
+
yield early
|
|
2011
|
+
break
|
|
2012
|
+
used.add(recovery)
|
|
2013
|
+
if recovery == "overflow":
|
|
2014
|
+
if not await self._compact_for_overflow():
|
|
2015
|
+
# Recovery failed: the original error is the answer.
|
|
2016
|
+
for early in withheld:
|
|
2017
|
+
yield early
|
|
2018
|
+
completed = True
|
|
2019
|
+
break
|
|
2020
|
+
notice = "Context window exceeded; compacted older history and retrying."
|
|
2021
|
+
else:
|
|
2022
|
+
failed = self._route.backend
|
|
2023
|
+
self._route = RoutePin()
|
|
2024
|
+
self._apply_route()
|
|
2025
|
+
self._sync_route_to_catalog()
|
|
2026
|
+
notice = f"Inference provider {failed} failed; retrying on the router's choice."
|
|
2027
|
+
yield RetryEvent(attempt=2, max_attempts=2, message=notice)
|
|
2028
|
+
stream = self._harness.resume()
|
|
2029
|
+
finally:
|
|
2030
|
+
if completed:
|
|
2031
|
+
await self._post_run()
|
|
2032
|
+
|
|
2033
|
+
async def _compact_for_overflow(self) -> bool:
|
|
2034
|
+
"""Compact after a context-overflow error; ``False`` if that is impossible."""
|
|
2035
|
+
try:
|
|
2036
|
+
await self.compact()
|
|
2037
|
+
except Exception: # noqa: BLE001 - the overflow error itself stays visible
|
|
2038
|
+
return False
|
|
2039
|
+
return True
|
|
2040
|
+
|
|
2041
|
+
async def _post_run(self) -> None:
|
|
2042
|
+
"""Housekeeping after a completed agent run."""
|
|
2043
|
+
evidence = getattr(self, "evidence", None)
|
|
2044
|
+
if evidence is not None:
|
|
2045
|
+
evidence.task_finished(self)
|
|
2046
|
+
refinement = getattr(self, "refinement", None)
|
|
2047
|
+
if refinement is not None:
|
|
2048
|
+
refinement.at_boundary()
|
|
2049
|
+
improver = getattr(self, "improver", None)
|
|
2050
|
+
if improver is not None:
|
|
2051
|
+
try:
|
|
2052
|
+
await improver.at_boundary()
|
|
2053
|
+
improver.show_digest()
|
|
2054
|
+
except Exception: # noqa: BLE001
|
|
2055
|
+
pass
|
|
2056
|
+
if self._tip_id:
|
|
2057
|
+
await self._append_record(
|
|
2058
|
+
TipRecord(entry_id=self._tip_id), advance_tip=False
|
|
2059
|
+
)
|
|
2060
|
+
|
|
2061
|
+
asked = getattr(self, "compact_requested", False)
|
|
2062
|
+
self.compact_requested = False
|
|
2063
|
+
if (
|
|
2064
|
+
(asked or self.should_compact())
|
|
2065
|
+
and len(self._harness.transcript) >= _CONTEXT_LIMITS.min_entries
|
|
2066
|
+
):
|
|
2067
|
+
try:
|
|
2068
|
+
await self.compact()
|
|
2069
|
+
except Exception: # noqa: BLE001
|
|
2070
|
+
pass
|
|
2071
|
+
if refinement is not None:
|
|
2072
|
+
refinement.settled()
|
|
2073
|
+
|
|
2074
|
+
# ── internal: persistence ──────────────────────────────────────────
|
|
2075
|
+
|
|
2076
|
+
async def _append_record(
|
|
2077
|
+
self,
|
|
2078
|
+
record: SessionRecord,
|
|
2079
|
+
*,
|
|
2080
|
+
advance_tip: bool = True,
|
|
2081
|
+
) -> str | None:
|
|
2082
|
+
"""Chain *record* onto the active branch and persist it."""
|
|
2083
|
+
if self._vault is None:
|
|
2084
|
+
return None
|
|
2085
|
+
if self._pending_header is not None:
|
|
2086
|
+
if not await self._write_header():
|
|
2087
|
+
return None
|
|
2088
|
+
if record.parent_id is None:
|
|
2089
|
+
record.parent_id = self._tip_id
|
|
2090
|
+
try:
|
|
2091
|
+
await self._vault.append(record)
|
|
2092
|
+
except Exception as error:
|
|
2093
|
+
raise OSError(f"Could not save session {self._session_id} to {self._vault.path}: {error}") from error
|
|
2094
|
+
if advance_tip:
|
|
2095
|
+
self._tip_id = record.id
|
|
2096
|
+
return record.id
|
|
2097
|
+
|
|
2098
|
+
async def _write_header(self) -> bool:
|
|
2099
|
+
"""Write the deferred session header and index row, once."""
|
|
2100
|
+
header = self._pending_header
|
|
2101
|
+
assert header is not None and self._vault is not None
|
|
2102
|
+
try:
|
|
2103
|
+
await self._vault.append(header)
|
|
2104
|
+
except Exception as error:
|
|
2105
|
+
raise OSError(f"Could not create session {self._session_id} at {self._vault.path}: {error}") from error
|
|
2106
|
+
self._pending_header = None
|
|
2107
|
+
if self._catalog is not None:
|
|
2108
|
+
self._catalog.upsert(replace(
|
|
2109
|
+
SessionCatalog.prepare(
|
|
2110
|
+
session_id=self._session_id,
|
|
2111
|
+
vault_path=self._vault.path,
|
|
2112
|
+
cwd=header.cwd or self._cwd,
|
|
2113
|
+
model=self._model,
|
|
2114
|
+
provider=self._provider_name,
|
|
2115
|
+
),
|
|
2116
|
+
title=self._title,
|
|
2117
|
+
inference_provider=self._route.backend,
|
|
2118
|
+
inference_provider_mode=self._route.mode,
|
|
2119
|
+
))
|
|
2120
|
+
return True
|
|
2121
|
+
|
|
2122
|
+
async def _persist_entry(self, entry: TranscriptEntry) -> str | None:
|
|
2123
|
+
"""Append a transcript record to the vault, returning its record ID."""
|
|
2124
|
+
return await self._append_record(TranscriptRecord(message=entry))
|
|
2125
|
+
|
|
2126
|
+
# ── internal: token estimation ─────────────────────────────────────
|
|
2127
|
+
|
|
2128
|
+
def _estimate_context_tokens(self) -> int:
|
|
2129
|
+
"""Estimated tokens currently occupying the context window."""
|
|
2130
|
+
return self.context_estimate().used
|
|
2131
|
+
|
|
2132
|
+
def _context_limit(self) -> int:
|
|
2133
|
+
"""Return the context token limit for the current model."""
|
|
2134
|
+
return resolve_window(self._model)
|
|
2135
|
+
|
|
2136
|
+
# ── internal: utility completions ──────────────────────────────────
|
|
2137
|
+
|
|
2138
|
+
async def _utility_completion(self, prompt: str, system: str) -> str:
|
|
2139
|
+
"""Get a simple text completion from the provider for internal use."""
|
|
2140
|
+
messages: list[TranscriptEntry] = [HumanEntry(content=prompt)]
|
|
2141
|
+
async for event in self._provider.stream_response(
|
|
2142
|
+
model=self._model,
|
|
2143
|
+
system=system,
|
|
2144
|
+
messages=messages,
|
|
2145
|
+
tools=[],
|
|
2146
|
+
):
|
|
2147
|
+
if isinstance(event, StreamCloseEvent):
|
|
2148
|
+
from rlmagent.harness.contracts.transcript import CallBlock
|
|
2149
|
+
if any(isinstance(part, CallBlock) for part in event.message.content):
|
|
2150
|
+
raise ValueError("Expected a text response; received a tool call")
|
|
2151
|
+
return event.message.text
|
|
2152
|
+
return ""
|
|
2153
|
+
|
|
2154
|
+
# ── internal: command handlers ─────────────────────────────────────
|
|
2155
|
+
|
|
2156
|
+
async def _cmd_model(self, arg: str) -> str:
|
|
2157
|
+
if not arg:
|
|
2158
|
+
return f"Current model: {self._model}"
|
|
2159
|
+
if arg.split()[0] == "cycle":
|
|
2160
|
+
from rlmagent.model.preferences import cycle, read
|
|
2161
|
+
|
|
2162
|
+
words = arg.split(maxsplit=2)
|
|
2163
|
+
operation = words[1] if len(words) > 1 else "next"
|
|
2164
|
+
if operation in ("add", "remove"):
|
|
2165
|
+
try:
|
|
2166
|
+
models = cycle(self._provider_name, operation, words[2] if len(words) > 2 else None)
|
|
2167
|
+
except ValueError as error:
|
|
2168
|
+
return str(error)
|
|
2169
|
+
return "Model cycle: " + (", ".join(models) or "empty")
|
|
2170
|
+
models = read(self._provider_name)["cycle"]
|
|
2171
|
+
if operation == "list":
|
|
2172
|
+
return "Model cycle: " + (", ".join(models) or "empty")
|
|
2173
|
+
if operation != "next":
|
|
2174
|
+
return "Use /model cycle add|remove NAME, list, or next."
|
|
2175
|
+
if not models:
|
|
2176
|
+
return "The provider's model cycle is empty. Use /model cycle add NAME."
|
|
2177
|
+
arg = models[(models.index(self.model) + 1) % len(models)] if self.model in models else models[0]
|
|
2178
|
+
await self.switch_model(arg)
|
|
2179
|
+
return f"Switched to model: {arg}"
|
|
2180
|
+
|
|
2181
|
+
async def _cmd_provider(self, arg: str) -> str:
|
|
2182
|
+
if not arg:
|
|
2183
|
+
return f"Current provider: {self._provider_name}"
|
|
2184
|
+
from rlmagent.config.config import resolve_provider
|
|
2185
|
+
|
|
2186
|
+
provider = resolve_provider(arg)
|
|
2187
|
+
await self.switch_provider(provider, arg)
|
|
2188
|
+
return f"Switched to provider: {arg}"
|
|
2189
|
+
|
|
2190
|
+
async def _cmd_think(self, arg: str) -> str:
|
|
2191
|
+
if not arg or arg.lower() in {"off", "none", "disable"}:
|
|
2192
|
+
await self.set_thinking(None)
|
|
2193
|
+
return "Thinking disabled."
|
|
2194
|
+
await self.set_thinking(arg.split()[0])
|
|
2195
|
+
return f"Thinking set to: {arg.split()[0]}"
|
|
2196
|
+
|
|
2197
|
+
async def _cmd_tier(self, arg: str) -> str:
|
|
2198
|
+
setter = getattr(self._provider, "set_service_tier", None)
|
|
2199
|
+
if setter is None:
|
|
2200
|
+
return "The current provider does not expose service tiers."
|
|
2201
|
+
value = arg.strip().lower()
|
|
2202
|
+
if value not in ("off", "auto", "default", "flex", "priority"):
|
|
2203
|
+
return "Use /tier auto|default|flex|priority|off. Provider pricing applies."
|
|
2204
|
+
try:
|
|
2205
|
+
setter(None if value == "off" else value)
|
|
2206
|
+
except ValueError as error:
|
|
2207
|
+
return str(error)
|
|
2208
|
+
from rlmagent.model.preferences import tier
|
|
2209
|
+
|
|
2210
|
+
tier(self._provider_name, self.model, None if value == "off" else value)
|
|
2211
|
+
return f"Service tier set to {value} for subsequent requests."
|
|
2212
|
+
|
|
2213
|
+
async def _cmd_fast(self, arg: str) -> str:
|
|
2214
|
+
if arg.strip().lower() not in ("on", "off"):
|
|
2215
|
+
return "Use /fast on|off. Priority service pricing applies when enabled."
|
|
2216
|
+
return await self._cmd_tier("priority" if arg.strip().lower() == "on" else "default")
|
|
2217
|
+
|
|
2218
|
+
def _cmd_plan(self, arg: str) -> str:
|
|
2219
|
+
"""Toggle plan mode, or set it explicitly with ``on``/``off``."""
|
|
2220
|
+
argument = arg.strip().lower()
|
|
2221
|
+
if not argument:
|
|
2222
|
+
enabled = not self._plan_mode
|
|
2223
|
+
elif argument in {"on", "start", "enable"}:
|
|
2224
|
+
enabled = True
|
|
2225
|
+
elif argument in {"off", "stop", "disable", "exit"}:
|
|
2226
|
+
enabled = False
|
|
2227
|
+
else:
|
|
2228
|
+
return f"Unknown argument: {arg!r}. Use /plan [on|off]."
|
|
2229
|
+
|
|
2230
|
+
if enabled is self._plan_mode:
|
|
2231
|
+
return f"Plan mode already {'on' if enabled else 'off'}."
|
|
2232
|
+
|
|
2233
|
+
self.set_plan_mode(enabled)
|
|
2234
|
+
if enabled:
|
|
2235
|
+
return (
|
|
2236
|
+
"Plan mode on — read-only tools, no edits. "
|
|
2237
|
+
"Use /plan off to resume editing."
|
|
2238
|
+
)
|
|
2239
|
+
return "Plan mode off — editing tools restored."
|
|
2240
|
+
|
|
2241
|
+
def _cmd_tools(self, _arg: str) -> str:
|
|
2242
|
+
"""What this session can really call, read from what is loaded."""
|
|
2243
|
+
lines = ["Tools the model is offered: "
|
|
2244
|
+
+ (", ".join(sorted(t.name for t in self.tools)) or "(none)")]
|
|
2245
|
+
backend = getattr(self, "backend", None)
|
|
2246
|
+
if backend is not None:
|
|
2247
|
+
lines.append("Functions in a cell: " + ", ".join(backend.function_names()))
|
|
2248
|
+
tree = getattr(self, "agent_tree", None)
|
|
2249
|
+
if tree is not None:
|
|
2250
|
+
from rlmagent.backend import limits_of
|
|
2251
|
+
|
|
2252
|
+
lines.append("Limits: " + ", ".join(f"{k}={v}" for k, v in limits_of(tree).items()))
|
|
2253
|
+
return "\n".join(lines)
|
|
2254
|
+
|
|
2255
|
+
async def _cmd_refine(self, arg: str) -> str:
|
|
2256
|
+
refinement = getattr(self, "refinement", None)
|
|
2257
|
+
if refinement is None:
|
|
2258
|
+
return "Manual refinement is not enabled for this session."
|
|
2259
|
+
return await refinement.command(arg)
|
|
2260
|
+
|
|
2261
|
+
async def _cmd_improve(self, arg: str) -> str:
|
|
2262
|
+
improver = getattr(self, "improver", None)
|
|
2263
|
+
if improver is None:
|
|
2264
|
+
return "The playbook is not available in this run."
|
|
2265
|
+
return await improver.command(arg)
|
|
2266
|
+
|
|
2267
|
+
async def _cmd_compact(self, _arg: str) -> str:
|
|
2268
|
+
try:
|
|
2269
|
+
entry = await self.compact()
|
|
2270
|
+
preview = entry.summary[:100]
|
|
2271
|
+
return f"Compacted. Summary: {preview}..."
|
|
2272
|
+
except ValueError as exc:
|
|
2273
|
+
return str(exc)
|
|
2274
|
+
|
|
2275
|
+
async def _cmd_stats(self, _arg: str) -> str:
|
|
2276
|
+
s = self.usage
|
|
2277
|
+
report = (
|
|
2278
|
+
f"Turns: {s.turn_count} Messages: {s.message_count}\n"
|
|
2279
|
+
f"Input tokens: {s.total_input_tokens:,} "
|
|
2280
|
+
f"Output tokens: {s.total_output_tokens:,}\n"
|
|
2281
|
+
f"Cost: ${s.total_cost_usd:.4f}\n"
|
|
2282
|
+
f"Context: ~{s.estimated_context_tokens:,} / "
|
|
2283
|
+
f"{s.context_limit:,} tokens"
|
|
2284
|
+
)
|
|
2285
|
+
latest, overall = s.latest_cache_hit_rate, s.cache_hit_rate
|
|
2286
|
+
if latest is not None and overall is not None:
|
|
2287
|
+
report += (
|
|
2288
|
+
f"\nCache hit: latest {latest:.0%} session {overall:.0%} "
|
|
2289
|
+
f"(read {s.cache_read_tokens:,}, written {s.cache_write_tokens:,})"
|
|
2290
|
+
)
|
|
2291
|
+
return report
|
|
2292
|
+
|
|
2293
|
+
async def note_playbook_change(self, change: dict) -> None:
|
|
2294
|
+
self.__dict__.setdefault("_playbook_log", []).append(change)
|
|
2295
|
+
await self._append_record(ExtensionRecord(namespace=_PLAYBOOK_NAMESPACE, data=change))
|
|
2296
|
+
|
|
2297
|
+
async def playbook_changes(self) -> list[dict]:
|
|
2298
|
+
if self._vault is None:
|
|
2299
|
+
return list(self.__dict__.get("_playbook_log", []))
|
|
2300
|
+
return [
|
|
2301
|
+
dict(r.data) for r in await self._vault.read_all()
|
|
2302
|
+
if isinstance(r, ExtensionRecord) and r.namespace == _PLAYBOOK_NAMESPACE
|
|
2303
|
+
]
|
|
2304
|
+
|
|
2305
|
+
async def note_sub_agent(self, session_id: str) -> None:
|
|
2306
|
+
await self._append_record(
|
|
2307
|
+
ExtensionRecord(namespace=_SUB_AGENT_NAMESPACE, data={"session_id": session_id})
|
|
2308
|
+
)
|
|
2309
|
+
|
|
2310
|
+
async def sub_agent_sessions(self) -> list[str]:
|
|
2311
|
+
"""Session ids of the sub-agents this session started, in order."""
|
|
2312
|
+
if self._vault is None:
|
|
2313
|
+
return []
|
|
2314
|
+
return [
|
|
2315
|
+
str(r.data["session_id"])
|
|
2316
|
+
for r in await self._vault.read_all()
|
|
2317
|
+
if isinstance(r, ExtensionRecord) and r.namespace == _SUB_AGENT_NAMESPACE
|
|
2318
|
+
]
|
|
2319
|
+
|
|
2320
|
+
async def set_name(self, title: str) -> None:
|
|
2321
|
+
"""Give the session a fixed name, so no model call is spent on titling it."""
|
|
2322
|
+
self._title = title
|
|
2323
|
+
self._named = True
|
|
2324
|
+
# An explicit name is final — stop auto-renaming over it.
|
|
2325
|
+
self._renamed_manually = True
|
|
2326
|
+
await self._append_record(TagRecord(label=title))
|
|
2327
|
+
self._sync_title_to_catalog(title)
|
|
2328
|
+
|
|
2329
|
+
async def _cmd_name(self, arg: str) -> str:
|
|
2330
|
+
if not arg:
|
|
2331
|
+
return f"Session: {self._title or self._session_id}"
|
|
2332
|
+
await self.set_name(arg)
|
|
2333
|
+
return f"Session named: {arg}"
|
|
2334
|
+
|
|
2335
|
+
async def _cmd_export(self, arg: str) -> str:
|
|
2336
|
+
fmt, destination = parse_export_arg(arg)
|
|
2337
|
+
if fmt not in _EXPORT_FORMATS:
|
|
2338
|
+
valid = ", ".join(sorted(_EXPORT_FORMATS))
|
|
2339
|
+
return f"Unknown format {fmt!r}. Choose one of: {valid}"
|
|
2340
|
+
|
|
2341
|
+
content = await self.export(fmt)
|
|
2342
|
+
if destination is None:
|
|
2343
|
+
return content
|
|
2344
|
+
|
|
2345
|
+
try:
|
|
2346
|
+
path = self.export_to_path(content, destination, fmt)
|
|
2347
|
+
except OSError as exc:
|
|
2348
|
+
return f"Could not write export: {exc}"
|
|
2349
|
+
return f"Exported {fmt} to {path}"
|
|
2350
|
+
|
|
2351
|
+
def export_to_path(self, content: str, destination: str, fmt: str) -> Path:
|
|
2352
|
+
"""Write *content* to *destination*, resolving directories and ``~``."""
|
|
2353
|
+
target = Path(destination).expanduser()
|
|
2354
|
+
if not target.is_absolute():
|
|
2355
|
+
target = Path(self.cwd) / target
|
|
2356
|
+
|
|
2357
|
+
looks_like_dir = target.is_dir() or destination.endswith(("/", "\\"))
|
|
2358
|
+
if looks_like_dir:
|
|
2359
|
+
target = target / f"{self._session_id}.{_EXPORT_SUFFIX[fmt]}"
|
|
2360
|
+
|
|
2361
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
2362
|
+
target.write_text(content, encoding="utf-8")
|
|
2363
|
+
return target
|
|
2364
|
+
|
|
2365
|
+
async def _cmd_branch(self, arg: str) -> str:
|
|
2366
|
+
summary = arg or None
|
|
2367
|
+
branch_id = await self.branch(summary)
|
|
2368
|
+
return f"Branch created: {branch_id}"
|
|
2369
|
+
|
|
2370
|
+
async def _cmd_rewind(self, arg: str) -> str:
|
|
2371
|
+
"""Rewind by row number from ``/rewind``, or by raw entry id."""
|
|
2372
|
+
choices = await self.tree_choices()
|
|
2373
|
+
if not choices:
|
|
2374
|
+
return "Nothing to rewind to — this session has no stored history."
|
|
2375
|
+
|
|
2376
|
+
if not arg:
|
|
2377
|
+
return self._render_tree(choices, header="Rewind to which entry?")
|
|
2378
|
+
|
|
2379
|
+
target = arg.strip()
|
|
2380
|
+
if target.isdigit():
|
|
2381
|
+
index = int(target) - 1
|
|
2382
|
+
if not 0 <= index < len(choices):
|
|
2383
|
+
return f"No entry {target}. Run /rewind to list them."
|
|
2384
|
+
target = choices[index].entry_id
|
|
2385
|
+
|
|
2386
|
+
try:
|
|
2387
|
+
await self.rewind(target)
|
|
2388
|
+
except Exception as exc: # noqa: BLE001
|
|
2389
|
+
return f"Rewind failed: {exc}"
|
|
2390
|
+
return f"Rewound to: {target}"
|
|
2391
|
+
|
|
2392
|
+
async def _cmd_branches(self, _arg: str) -> str:
|
|
2393
|
+
"""List fork points recorded in this session."""
|
|
2394
|
+
choices = await self.tree_choices()
|
|
2395
|
+
forks = tuple(c for c in choices if c.label.lstrip().startswith("branch summary:"))
|
|
2396
|
+
if not forks:
|
|
2397
|
+
return "No branches yet. Use /branch to fork the conversation here."
|
|
2398
|
+
return self._render_tree(forks, header="Branches:")
|
|
2399
|
+
|
|
2400
|
+
@staticmethod
|
|
2401
|
+
def _render_tree(
|
|
2402
|
+
choices: tuple[SessionTreeChoice, ...],
|
|
2403
|
+
*,
|
|
2404
|
+
header: str,
|
|
2405
|
+
) -> str:
|
|
2406
|
+
"""Render tree rows as a numbered, selectable list."""
|
|
2407
|
+
lines = [header]
|
|
2408
|
+
for number, choice in enumerate(choices, start=1):
|
|
2409
|
+
marker = "*" if choice.active else " "
|
|
2410
|
+
lines.append(f"{marker}{number:>3}. {choice.label}")
|
|
2411
|
+
lines.append("")
|
|
2412
|
+
lines.append("Pick with /rewind <number>.")
|
|
2413
|
+
return "\n".join(lines)
|
|
2414
|
+
|
|
2415
|
+
async def _cmd_shell(self, arg: str) -> str:
|
|
2416
|
+
if not arg:
|
|
2417
|
+
return "Usage: /shell <command>"
|
|
2418
|
+
entry = await self.run_shell(arg)
|
|
2419
|
+
return f"Exit {entry.exit_code}:\n{entry.output}"
|
|
2420
|
+
|
|
2421
|
+
async def _cmd_create_skill(self, arg: str) -> str:
|
|
2422
|
+
"""Write everything after the name into a new project skill."""
|
|
2423
|
+
name, body = parse_create_skill_arg(arg)
|
|
2424
|
+
|
|
2425
|
+
if not name:
|
|
2426
|
+
return (
|
|
2427
|
+
"Usage: /create-skill <name> <instructions>\n"
|
|
2428
|
+
"Everything after the name becomes the skill body "
|
|
2429
|
+
"(Ctrl+J for multi-line)."
|
|
2430
|
+
)
|
|
2431
|
+
if not _SKILL_NAME_RE.match(name):
|
|
2432
|
+
return (
|
|
2433
|
+
f"Invalid skill name {name!r}. Use letters, digits, "
|
|
2434
|
+
"dots, dashes or underscores — no path separators."
|
|
2435
|
+
)
|
|
2436
|
+
if not body:
|
|
2437
|
+
return f"Nothing to save. Add the instructions after '{name}'."
|
|
2438
|
+
|
|
2439
|
+
try:
|
|
2440
|
+
path, existed = self.write_skill(name, body)
|
|
2441
|
+
except OSError as exc:
|
|
2442
|
+
return f"Could not write skill: {exc}"
|
|
2443
|
+
|
|
2444
|
+
count = self.reload_skills()
|
|
2445
|
+
verb = "Updated" if existed else "Created"
|
|
2446
|
+
return (
|
|
2447
|
+
f"{verb} skill '{name}' at {path}\n"
|
|
2448
|
+
f"{count} skill(s) loaded. Invoke it with /skill:{name}"
|
|
2449
|
+
)
|
|
2450
|
+
|
|
2451
|
+
def write_skill(self, name: str, body: str) -> tuple[Path, bool]:
|
|
2452
|
+
"""Write ``<cwd>/.rlm-agent/skills/<name>/SKILL.md``."""
|
|
2453
|
+
target = Path(self._cwd) / ".rlm-agent" / "skills" / name / "SKILL.md"
|
|
2454
|
+
existed = target.exists()
|
|
2455
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
2456
|
+
description = derive_skill_description(body)
|
|
2457
|
+
target.write_text(
|
|
2458
|
+
render_skill_file(name, description, body), encoding="utf-8",
|
|
2459
|
+
)
|
|
2460
|
+
return target, existed
|
|
2461
|
+
|
|
2462
|
+
async def _cmd_reload(self, _arg: str) -> str:
|
|
2463
|
+
await self.reload()
|
|
2464
|
+
return "Reloaded."
|
|
2465
|
+
|
|
2466
|
+
async def _cmd_skills(self, arg: str) -> str:
|
|
2467
|
+
"""List loaded skills, show one, or reload them from disk."""
|
|
2468
|
+
argument = arg.strip()
|
|
2469
|
+
|
|
2470
|
+
if argument in {"reload", "refresh"}:
|
|
2471
|
+
count = self.reload_skills()
|
|
2472
|
+
return f"Reloaded {count} skill(s)."
|
|
2473
|
+
|
|
2474
|
+
if argument:
|
|
2475
|
+
skill = self.get_skill(argument)
|
|
2476
|
+
if skill is None:
|
|
2477
|
+
known = ", ".join(s.name for s in self._skills) or "none"
|
|
2478
|
+
return f"Unknown skill {argument!r}. Loaded: {known}"
|
|
2479
|
+
return (
|
|
2480
|
+
f"{skill.name} — {skill.description}\n"
|
|
2481
|
+
f"source: {skill.source}\n\n{skill.body}"
|
|
2482
|
+
)
|
|
2483
|
+
|
|
2484
|
+
if not self._skills:
|
|
2485
|
+
return (
|
|
2486
|
+
"No skills loaded.\n"
|
|
2487
|
+
"Create .rlm-agent/skills/<name>/SKILL.md (or .agents/skills/<name>/, "
|
|
2488
|
+
"or the same under ~/), then run /skills reload."
|
|
2489
|
+
)
|
|
2490
|
+
lines = [f"{len(self._skills)} skill(s) loaded:"]
|
|
2491
|
+
for skill in self._skills:
|
|
2492
|
+
lines.append(f" /skill:{skill.name} — {skill.description}")
|
|
2493
|
+
lines.append("")
|
|
2494
|
+
lines.append("Use /skills <name> to view one, /skills reload to re-read.")
|
|
2495
|
+
return "\n".join(lines)
|
|
2496
|
+
|
|
2497
|
+
def system_sections(self) -> list[PromptSection]:
|
|
2498
|
+
"""The live system prompt split into attributed sections."""
|
|
2499
|
+
from rlmagent.instructions import prompt_sections
|
|
2500
|
+
|
|
2501
|
+
attributed: list[PromptSection] | None = None
|
|
2502
|
+
for cwd in dict.fromkeys((self._cwd, os.getcwd())):
|
|
2503
|
+
for dated in (True, False):
|
|
2504
|
+
candidate = prompt_sections(
|
|
2505
|
+
tools=self._all_tools, skills=self._skills, cwd=cwd, include_date=dated,
|
|
2506
|
+
)
|
|
2507
|
+
if "\n\n".join(part.text for part in candidate) == self._system:
|
|
2508
|
+
attributed = candidate
|
|
2509
|
+
break
|
|
2510
|
+
if attributed is not None:
|
|
2511
|
+
break
|
|
2512
|
+
if attributed is None:
|
|
2513
|
+
attributed = [PromptSection("System prompt", "runtime-composed", self._system)]
|
|
2514
|
+
|
|
2515
|
+
live = self._harness.settings.system
|
|
2516
|
+
if live != self._system and live.startswith(self._system + "\n\n"):
|
|
2517
|
+
attributed.append(PromptSection(
|
|
2518
|
+
"Plan mode", "plan mode (active)", live[len(self._system) + 2:],
|
|
2519
|
+
))
|
|
2520
|
+
return attributed
|
|
2521
|
+
|
|
2522
|
+
def _cmd_session(self, _arg: str) -> str:
|
|
2523
|
+
lines = [
|
|
2524
|
+
f"Session: {self._session_id}",
|
|
2525
|
+
f"Title: {self._title or '(untitled)'}",
|
|
2526
|
+
f"Model: {self._model}",
|
|
2527
|
+
f"Provider: {self._provider_name}",
|
|
2528
|
+
f"CWD: {self._cwd}",
|
|
2529
|
+
]
|
|
2530
|
+
if self._provider_name == ROUTED_PROVIDER:
|
|
2531
|
+
lines.append(f"Hugging Face inference provider: {self._route.describe()}")
|
|
2532
|
+
return "\n".join(lines)
|
|
2533
|
+
|
|
2534
|
+
def _cmd_system(self, _arg: str) -> str:
|
|
2535
|
+
parts = []
|
|
2536
|
+
for number, section in enumerate(self.system_sections(), start=1):
|
|
2537
|
+
parts.append(f"── {number}. {section.title} [{section.origin}]\n{section.text}")
|
|
2538
|
+
return "\n\n".join(parts)
|
|
2539
|
+
|
|
2540
|
+
async def _cmd_diag(self, _arg: str) -> str:
|
|
2541
|
+
return json.dumps(self.diagnostics(), indent=2)
|
|
2542
|
+
|
|
2543
|
+
|
|
2544
|
+
__all__ = [
|
|
2545
|
+
"CodingSession",
|
|
2546
|
+
"SessionStats",
|
|
2547
|
+
]
|