rlmness-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rlmagent/__init__.py +1 -0
- rlmagent/__main__.py +8 -0
- rlmagent/acp/__init__.py +1 -0
- rlmagent/acp/runtime.py +71 -0
- rlmagent/acp/server.py +598 -0
- rlmagent/acp/updates.py +128 -0
- rlmagent/agentnet.py +237 -0
- rlmagent/agents/__init__.py +1 -0
- rlmagent/agents/approval.py +90 -0
- rlmagent/agents/bridge.py +109 -0
- rlmagent/agents/budget.py +212 -0
- rlmagent/agents/child_handles.py +46 -0
- rlmagent/agents/child_mailbox.py +30 -0
- rlmagent/agents/kernel_api.py +156 -0
- rlmagent/agents/output.py +34 -0
- rlmagent/agents/patch.py +107 -0
- rlmagent/agents/prompts.py +156 -0
- rlmagent/agents/trace.py +290 -0
- rlmagent/agents/tree.py +682 -0
- rlmagent/agents/wasm_api.py +105 -0
- rlmagent/agents/workspace.py +357 -0
- rlmagent/autopilot.py +447 -0
- rlmagent/backend.py +946 -0
- rlmagent/backend_context.py +74 -0
- rlmagent/cli/__init__.py +5 -0
- rlmagent/cli/config_cmd.py +177 -0
- rlmagent/cli/health.py +156 -0
- rlmagent/cli/main.py +1194 -0
- rlmagent/cli/update.py +188 -0
- rlmagent/clipboard.py +63 -0
- rlmagent/config/__init__.py +37 -0
- rlmagent/config/config.py +297 -0
- rlmagent/config/loader.py +119 -0
- rlmagent/config/prompts.py +124 -0
- rlmagent/config/secure_input.py +213 -0
- rlmagent/config/store.py +275 -0
- rlmagent/context/__init__.py +47 -0
- rlmagent/context/budget.py +557 -0
- rlmagent/contract.py +311 -0
- rlmagent/contract_run.py +232 -0
- rlmagent/conversation.py +2547 -0
- rlmagent/cron.py +77 -0
- rlmagent/directives.py +893 -0
- rlmagent/discovery.py +356 -0
- rlmagent/execution_log.py +66 -0
- rlmagent/factory.py +189 -0
- rlmagent/factory_graph.py +413 -0
- rlmagent/harness/__init__.py +63 -0
- rlmagent/harness/contracts/__init__.py +1 -0
- rlmagent/harness/contracts/stream.py +129 -0
- rlmagent/harness/contracts/tooling.py +127 -0
- rlmagent/harness/contracts/transcript/__init__.py +68 -0
- rlmagent/harness/contracts/transcript/_base.py +29 -0
- rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
- rlmagent/harness/contracts/transcript/entries.py +180 -0
- rlmagent/harness/contracts/transcript/segments.py +64 -0
- rlmagent/harness/contracts/values.py +6 -0
- rlmagent/harness/driver.py +397 -0
- rlmagent/harness/engine.py +489 -0
- rlmagent/harness/mending.py +153 -0
- rlmagent/harness/provider/__init__.py +1 -0
- rlmagent/harness/provider/base.py +27 -0
- rlmagent/harness/provider/wire.py +189 -0
- rlmagent/harness/py.typed +0 -0
- rlmagent/harness/session/__init__.py +5 -0
- rlmagent/harness/session/index.py +218 -0
- rlmagent/harness/session/lease.py +66 -0
- rlmagent/harness/session/records.py +118 -0
- rlmagent/harness/session/replay.py +165 -0
- rlmagent/harness/session/store.py +155 -0
- rlmagent/harness/session/summary.py +46 -0
- rlmagent/hints.py +47 -0
- rlmagent/history.py +92 -0
- rlmagent/hooks/__init__.py +1 -0
- rlmagent/hooks/hooks.py +1 -0
- rlmagent/instructions.py +157 -0
- rlmagent/job_runner.py +117 -0
- rlmagent/jobs.py +365 -0
- rlmagent/kept_agents.py +397 -0
- rlmagent/kernel/__init__.py +5 -0
- rlmagent/kernel/session.py +205 -0
- rlmagent/kernel/wasm.py +169 -0
- rlmagent/logs.py +208 -0
- rlmagent/managed_release.py +368 -0
- rlmagent/mcp.py +411 -0
- rlmagent/mcp_oauth.py +213 -0
- rlmagent/model/__init__.py +29 -0
- rlmagent/model/_claude/__init__.py +1 -0
- rlmagent/model/_claude/courier.py +117 -0
- rlmagent/model/_claude/emitter.py +378 -0
- rlmagent/model/_oai/__init__.py +1 -0
- rlmagent/model/_oai/helpers.py +256 -0
- rlmagent/model/_oai/normalize.py +263 -0
- rlmagent/model/_oai/parsers.py +317 -0
- rlmagent/model/_oai/payloads.py +226 -0
- rlmagent/model/_oai/transport.py +127 -0
- rlmagent/model/claude.py +552 -0
- rlmagent/model/copilot.py +142 -0
- rlmagent/model/correlation.py +21 -0
- rlmagent/model/deadline.py +19 -0
- rlmagent/model/discovery.py +181 -0
- rlmagent/model/external/__init__.py +11 -0
- rlmagent/model/external/acp.py +202 -0
- rlmagent/model/external/acp_install.py +109 -0
- rlmagent/model/external/cli.py +248 -0
- rlmagent/model/external/textcalls.py +57 -0
- rlmagent/model/failover.py +129 -0
- rlmagent/model/google.py +199 -0
- rlmagent/model/limits.py +46 -0
- rlmagent/model/oai_compatible.py +414 -0
- rlmagent/model/preferences.py +56 -0
- rlmagent/model/py.typed +0 -0
- rlmagent/model/registered_oauth.py +195 -0
- rlmagent/model/scripted.py +55 -0
- rlmagent/model/settings.py +271 -0
- rlmagent/model/subscription.py +398 -0
- rlmagent/model/token_bounds.py +15 -0
- rlmagent/model/transport/__init__.py +1 -0
- rlmagent/model/transport/backoff.py +87 -0
- rlmagent/model/transport/client.py +87 -0
- rlmagent/model/transport/faults.py +65 -0
- rlmagent/model/transport/responses_socket.py +223 -0
- rlmagent/model/vertex.py +93 -0
- rlmagent/network.py +123 -0
- rlmagent/packages.py +246 -0
- rlmagent/planning.py +84 -0
- rlmagent/playbook/__init__.py +1 -0
- rlmagent/playbook/apply.py +106 -0
- rlmagent/playbook/digest.py +63 -0
- rlmagent/playbook/improver.py +272 -0
- rlmagent/playbook/plan.py +99 -0
- rlmagent/playbook/store.py +67 -0
- rlmagent/plugins.py +327 -0
- rlmagent/policy.py +105 -0
- rlmagent/prompt_refs.py +57 -0
- rlmagent/prompts.py +331 -0
- rlmagent/prune.py +60 -0
- rlmagent/py.typed +0 -0
- rlmagent/reasoning.py +135 -0
- rlmagent/refinement/__init__.py +1 -0
- rlmagent/refinement/controller.py +448 -0
- rlmagent/refinement/engine.py +220 -0
- rlmagent/refinement/evidence.py +131 -0
- rlmagent/refinement/scoped.py +90 -0
- rlmagent/refinement/store.py +193 -0
- rlmagent/refresh.py +202 -0
- rlmagent/routing.py +52 -0
- rlmagent/runtime.py +575 -0
- rlmagent/safety.py +267 -0
- rlmagent/session_html.py +57 -0
- rlmagent/session_import.py +52 -0
- rlmagent/session_share.py +55 -0
- rlmagent/sessions.py +249 -0
- rlmagent/shell_sandbox.py +88 -0
- rlmagent/skillset.py +320 -0
- rlmagent/steering.py +148 -0
- rlmagent/stops.py +62 -0
- rlmagent/subscriptions.py +219 -0
- rlmagent/tasks.py +98 -0
- rlmagent/toolhost.py +310 -0
- rlmagent/tools/__init__.py +56 -0
- rlmagent/tools/_shared.py +210 -0
- rlmagent/tools/files.py +462 -0
- rlmagent/tools/kernel.py +93 -0
- rlmagent/tools/patch_tool.py +68 -0
- rlmagent/tools/search.py +188 -0
- rlmagent/trust.py +383 -0
- rlmagent/tui/__init__.py +1 -0
- rlmagent/tui/adapter.py +506 -0
- rlmagent/tui/app.py +711 -0
- rlmagent/tui/approval_screen.py +57 -0
- rlmagent/tui/choice_screen.py +47 -0
- rlmagent/tui/completions.py +116 -0
- rlmagent/tui/components.py +25 -0
- rlmagent/tui/composer.py +40 -0
- rlmagent/tui/factory_view.py +86 -0
- rlmagent/tui/inspect.py +193 -0
- rlmagent/tui/messages.py +215 -0
- rlmagent/tui/model_screen.py +307 -0
- rlmagent/tui/prompt.py +98 -0
- rlmagent/tui/question_screen.py +52 -0
- rlmagent/tui/replay.py +141 -0
- rlmagent/tui/rlm_agent.tcss +405 -0
- rlmagent/tui/search_box.py +91 -0
- rlmagent/tui/sidebar.py +182 -0
- rlmagent/tui/status.py +49 -0
- rlmagent/tui/steps.py +283 -0
- rlmagent/tui/suggest.py +778 -0
- rlmagent/tui/transcript.py +172 -0
- rlmagent/tui/trust_dialog.py +58 -0
- rlmagent/tui/worker_attach.py +141 -0
- rlmagent/ui/__init__.py +1 -0
- rlmagent/ui/render.py +211 -0
- rlmagent/undo.py +109 -0
- rlmagent/utils/__init__.py +1 -0
- rlmagent/websearch.py +75 -0
- rlmagent/worker.py +1275 -0
- rlmagent/worker_service.py +203 -0
- rlmness_cli-0.2.0.dist-info/METADATA +882 -0
- rlmness_cli-0.2.0.dist-info/RECORD +203 -0
- rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
- rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
- rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Typed streaming signals emitted by model adapters and the agent wire protocol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
from rlmagent.harness.contracts.transcript import CallBlock, ModelEntry, StrictModel
|
|
10
|
+
from rlmagent.harness.contracts.values import JValue
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StreamOpenEvent(StrictModel):
|
|
14
|
+
type: Literal["start"] = "start"
|
|
15
|
+
partial: ModelEntry
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ContentOpenEvent(StrictModel):
|
|
19
|
+
type: Literal["text_start"] = "text_start"
|
|
20
|
+
content_index: int
|
|
21
|
+
partial: ModelEntry
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ContentChunkEvent(StrictModel):
|
|
25
|
+
type: Literal["text_delta"] = "text_delta"
|
|
26
|
+
content_index: int
|
|
27
|
+
delta: str
|
|
28
|
+
partial: ModelEntry
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ContentCloseEvent(StrictModel):
|
|
32
|
+
type: Literal["text_end"] = "text_end"
|
|
33
|
+
content_index: int
|
|
34
|
+
content: str
|
|
35
|
+
partial: ModelEntry
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ReasoningOpenEvent(StrictModel):
|
|
39
|
+
type: Literal["thinking_start"] = "thinking_start"
|
|
40
|
+
content_index: int
|
|
41
|
+
partial: ModelEntry
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ReasoningChunkEvent(StrictModel):
|
|
45
|
+
type: Literal["thinking_delta"] = "thinking_delta"
|
|
46
|
+
content_index: int
|
|
47
|
+
delta: str
|
|
48
|
+
partial: ModelEntry
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ReasoningCloseEvent(StrictModel):
|
|
52
|
+
type: Literal["thinking_end"] = "thinking_end"
|
|
53
|
+
content_index: int
|
|
54
|
+
content: str
|
|
55
|
+
partial: ModelEntry
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CallOpenEvent(StrictModel):
|
|
59
|
+
type: Literal["toolcall_start"] = "toolcall_start"
|
|
60
|
+
content_index: int
|
|
61
|
+
partial: ModelEntry
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class CallChunkEvent(StrictModel):
|
|
65
|
+
type: Literal["toolcall_delta"] = "toolcall_delta"
|
|
66
|
+
content_index: int
|
|
67
|
+
delta: str
|
|
68
|
+
partial: ModelEntry
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class CallCloseEvent(StrictModel):
|
|
72
|
+
type: Literal["toolcall_end"] = "toolcall_end"
|
|
73
|
+
content_index: int
|
|
74
|
+
tool_call: CallBlock
|
|
75
|
+
partial: ModelEntry
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
CompletionCause = Literal["stop", "length", "toolUse"]
|
|
79
|
+
FaultCause = Literal["aborted", "error"]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class StreamCloseEvent(StrictModel):
|
|
83
|
+
type: Literal["done"] = "done"
|
|
84
|
+
reason: CompletionCause
|
|
85
|
+
message: ModelEntry
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class StreamFaultEvent(StrictModel):
|
|
89
|
+
type: Literal["error"] = "error"
|
|
90
|
+
reason: FaultCause
|
|
91
|
+
error: ModelEntry
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
type WireEvent = Annotated[
|
|
95
|
+
StreamOpenEvent
|
|
96
|
+
| StreamCloseEvent
|
|
97
|
+
| StreamFaultEvent
|
|
98
|
+
| ContentOpenEvent
|
|
99
|
+
| ContentChunkEvent
|
|
100
|
+
| ContentCloseEvent
|
|
101
|
+
| ReasoningOpenEvent
|
|
102
|
+
| ReasoningChunkEvent
|
|
103
|
+
| ReasoningCloseEvent
|
|
104
|
+
| CallOpenEvent
|
|
105
|
+
| CallChunkEvent
|
|
106
|
+
| CallCloseEvent
|
|
107
|
+
| SourceRetryEvent,
|
|
108
|
+
Field(discriminator="type"),
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class SourceStartEvent(BaseModel):
|
|
113
|
+
"""Signals that a new model completion is being streamed."""
|
|
114
|
+
|
|
115
|
+
model_config = ConfigDict(extra="forbid")
|
|
116
|
+
|
|
117
|
+
type: Literal["response_start"] = "response_start"
|
|
118
|
+
model: str
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class SourceRetryEvent(BaseModel):
|
|
122
|
+
"""Adapter is backing off and reattempting after a transient failure."""
|
|
123
|
+
|
|
124
|
+
model_config = ConfigDict(extra="forbid")
|
|
125
|
+
|
|
126
|
+
type: Literal["retry"] = "retry"
|
|
127
|
+
attempt: int
|
|
128
|
+
max_attempts: int
|
|
129
|
+
delay_seconds: float
|
|
130
|
+
message: str
|
|
131
|
+
data: dict[str, JValue] | None = None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class SourceTextDelta(BaseModel):
|
|
135
|
+
"""An incremental chunk of plain text from the model."""
|
|
136
|
+
|
|
137
|
+
model_config = ConfigDict(extra="forbid")
|
|
138
|
+
|
|
139
|
+
type: Literal["text_delta"] = "text_delta"
|
|
140
|
+
delta: str
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class SourceThinkingDelta(BaseModel):
|
|
144
|
+
"""An incremental chunk of reasoning output from the model."""
|
|
145
|
+
|
|
146
|
+
model_config = ConfigDict(extra="forbid")
|
|
147
|
+
|
|
148
|
+
type: Literal["thinking_delta"] = "thinking_delta"
|
|
149
|
+
delta: str
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class SourceCallEvent(BaseModel):
|
|
153
|
+
"""A fully resolved tool invocation emitted by the model."""
|
|
154
|
+
|
|
155
|
+
model_config = ConfigDict(extra="forbid")
|
|
156
|
+
|
|
157
|
+
type: Literal["tool_call"] = "tool_call"
|
|
158
|
+
tool_call: CallBlock
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class SourceEndEvent(BaseModel):
|
|
162
|
+
"""The model response has been fully received and assembled."""
|
|
163
|
+
|
|
164
|
+
model_config = ConfigDict(extra="forbid")
|
|
165
|
+
|
|
166
|
+
type: Literal["response_end"] = "response_end"
|
|
167
|
+
message: ModelEntry
|
|
168
|
+
finish_reason: str | None = None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class SourceErrorEvent(BaseModel):
|
|
172
|
+
"""An adapter-level fault surfaced to the harness for display or recovery."""
|
|
173
|
+
|
|
174
|
+
model_config = ConfigDict(extra="forbid")
|
|
175
|
+
|
|
176
|
+
type: Literal["error"] = "error"
|
|
177
|
+
message: str
|
|
178
|
+
data: dict[str, JValue] | None = None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
type SourceEvent = (
|
|
182
|
+
SourceStartEvent
|
|
183
|
+
| SourceRetryEvent
|
|
184
|
+
| SourceTextDelta
|
|
185
|
+
| SourceThinkingDelta
|
|
186
|
+
| SourceCallEvent
|
|
187
|
+
| SourceEndEvent
|
|
188
|
+
| SourceErrorEvent
|
|
189
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Lightweight session metadata index backed by a JSONL file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from time import time
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
11
|
+
|
|
12
|
+
from rlmagent.harness.session.store import jsonl_lines
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Serialization model
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SessionMetaWire(BaseModel):
|
|
20
|
+
"""Pydantic model for (de)serializing a single index entry."""
|
|
21
|
+
|
|
22
|
+
model_config = ConfigDict(extra="forbid")
|
|
23
|
+
|
|
24
|
+
session_id: str
|
|
25
|
+
vault_path: str
|
|
26
|
+
cwd: str
|
|
27
|
+
model: str
|
|
28
|
+
provider: str
|
|
29
|
+
title: str | None = None
|
|
30
|
+
created_at: float = Field(default_factory=time)
|
|
31
|
+
updated_at: float = Field(default_factory=time)
|
|
32
|
+
inference_provider: str | None = None
|
|
33
|
+
inference_provider_mode: str | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Runtime representation
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class SessionMeta:
|
|
43
|
+
"""Immutable snapshot of session metadata for runtime use."""
|
|
44
|
+
|
|
45
|
+
session_id: str
|
|
46
|
+
vault_path: str
|
|
47
|
+
cwd: str
|
|
48
|
+
model: str
|
|
49
|
+
provider: str
|
|
50
|
+
title: str | None
|
|
51
|
+
created_at: float
|
|
52
|
+
updated_at: float
|
|
53
|
+
# Backend pin for routed providers, and whether it was learned or chosen.
|
|
54
|
+
inference_provider: str | None = None
|
|
55
|
+
inference_provider_mode: str | None = None
|
|
56
|
+
|
|
57
|
+
def to_wire(self) -> SessionMetaWire:
|
|
58
|
+
"""Convert to a serializable wire model."""
|
|
59
|
+
return SessionMetaWire(
|
|
60
|
+
session_id=self.session_id,
|
|
61
|
+
vault_path=self.vault_path,
|
|
62
|
+
cwd=self.cwd,
|
|
63
|
+
model=self.model,
|
|
64
|
+
provider=self.provider,
|
|
65
|
+
title=self.title,
|
|
66
|
+
created_at=self.created_at,
|
|
67
|
+
updated_at=self.updated_at,
|
|
68
|
+
inference_provider=self.inference_provider,
|
|
69
|
+
inference_provider_mode=self.inference_provider_mode,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _wire_to_meta(wire: SessionMetaWire) -> SessionMeta:
|
|
74
|
+
"""Convert a wire model to the frozen runtime representation."""
|
|
75
|
+
return SessionMeta(
|
|
76
|
+
session_id=wire.session_id,
|
|
77
|
+
vault_path=wire.vault_path,
|
|
78
|
+
cwd=wire.cwd,
|
|
79
|
+
model=wire.model,
|
|
80
|
+
provider=wire.provider,
|
|
81
|
+
title=wire.title,
|
|
82
|
+
created_at=wire.created_at,
|
|
83
|
+
updated_at=wire.updated_at,
|
|
84
|
+
inference_provider=wire.inference_provider,
|
|
85
|
+
inference_provider_mode=wire.inference_provider_mode,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# Catalog
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
_INDEX_FILENAME = "index.jsonl"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class SessionCatalog:
|
|
97
|
+
"""Persistent index of session metadata stored as JSONL."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, sessions_dir: str | Path) -> None:
|
|
100
|
+
self._dir = Path(sessions_dir)
|
|
101
|
+
self._path = self._dir / _INDEX_FILENAME
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def path(self) -> Path:
|
|
105
|
+
"""Absolute path to the backing ``index.jsonl``."""
|
|
106
|
+
return self._path
|
|
107
|
+
|
|
108
|
+
# ── factory helpers ───────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def prepare(
|
|
112
|
+
*,
|
|
113
|
+
session_id: str,
|
|
114
|
+
vault_path: str | Path,
|
|
115
|
+
cwd: str,
|
|
116
|
+
model: str,
|
|
117
|
+
provider: str,
|
|
118
|
+
title: str | None = None,
|
|
119
|
+
) -> SessionMeta:
|
|
120
|
+
"""Build a ``SessionMeta`` without writing it to any index."""
|
|
121
|
+
now = time()
|
|
122
|
+
return SessionMeta(
|
|
123
|
+
session_id=session_id,
|
|
124
|
+
vault_path=str(vault_path),
|
|
125
|
+
cwd=cwd,
|
|
126
|
+
model=model,
|
|
127
|
+
provider=provider,
|
|
128
|
+
title=title,
|
|
129
|
+
created_at=now,
|
|
130
|
+
updated_at=now,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# ── writes ────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
def upsert(self, meta: SessionMeta) -> None:
|
|
136
|
+
"""Insert or replace the entry for ``meta.session_id``."""
|
|
137
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
entries = self._load_deduped()
|
|
139
|
+
current = entries.get(meta.session_id)
|
|
140
|
+
if current is not None and meta.updated_at < current.updated_at:
|
|
141
|
+
return
|
|
142
|
+
entries[meta.session_id] = meta
|
|
143
|
+
|
|
144
|
+
payload = "".join(
|
|
145
|
+
entry.to_wire().model_dump_json(exclude_none=True) + "\n"
|
|
146
|
+
for entry in sorted(entries.values(), key=lambda m: m.updated_at)
|
|
147
|
+
)
|
|
148
|
+
staged = self._path.with_name(f"{self._path.name}.tmp")
|
|
149
|
+
staged.write_text(payload, encoding="utf-8")
|
|
150
|
+
staged.replace(self._path)
|
|
151
|
+
|
|
152
|
+
def remove(self, session_ids: set[str]) -> None:
|
|
153
|
+
"""Forget these sessions."""
|
|
154
|
+
entries = self._load_deduped()
|
|
155
|
+
payload = "".join(
|
|
156
|
+
entry.to_wire().model_dump_json(exclude_none=True) + "\n"
|
|
157
|
+
for entry in sorted(entries.values(), key=lambda m: m.updated_at)
|
|
158
|
+
if entry.session_id not in session_ids
|
|
159
|
+
)
|
|
160
|
+
staged = self._path.with_name(f"{self._path.name}.tmp")
|
|
161
|
+
staged.write_text(payload, encoding="utf-8")
|
|
162
|
+
staged.replace(self._path)
|
|
163
|
+
|
|
164
|
+
def touch(self, session_id: str) -> SessionMeta | None:
|
|
165
|
+
"""Update the ``updated_at`` timestamp for *session_id*."""
|
|
166
|
+
existing = self.get(session_id)
|
|
167
|
+
if existing is None:
|
|
168
|
+
return None
|
|
169
|
+
refreshed = replace(existing, updated_at=time())
|
|
170
|
+
self.upsert(refreshed)
|
|
171
|
+
return refreshed
|
|
172
|
+
|
|
173
|
+
# ── reads ─────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
def list_all(self) -> list[SessionMeta]:
|
|
176
|
+
"""Return all sessions ordered by ``updated_at`` descending."""
|
|
177
|
+
entries = self._load_deduped()
|
|
178
|
+
return sorted(entries.values(), key=lambda m: m.updated_at, reverse=True)
|
|
179
|
+
|
|
180
|
+
def get(self, session_id: str) -> SessionMeta | None:
|
|
181
|
+
"""Look up a single session by its id, or ``None`` if absent."""
|
|
182
|
+
entries = self._load_deduped()
|
|
183
|
+
return entries.get(session_id)
|
|
184
|
+
|
|
185
|
+
def latest_for_cwd(self, cwd: str) -> SessionMeta | None:
|
|
186
|
+
"""Return the most recently used session for *cwd*, or ``None``."""
|
|
187
|
+
candidates = [
|
|
188
|
+
m for m in self._load_deduped().values() if m.cwd == cwd
|
|
189
|
+
]
|
|
190
|
+
if not candidates:
|
|
191
|
+
return None
|
|
192
|
+
return max(candidates, key=lambda m: m.updated_at)
|
|
193
|
+
|
|
194
|
+
# ── internals ─────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
def _load_deduped(self) -> dict[str, SessionMeta]:
|
|
197
|
+
"""Read every line, parse, and deduplicate by session_id."""
|
|
198
|
+
if not self._path.exists():
|
|
199
|
+
return {}
|
|
200
|
+
|
|
201
|
+
result: dict[str, SessionMeta] = {}
|
|
202
|
+
for line in jsonl_lines(self._path.read_text(encoding="utf-8")):
|
|
203
|
+
stripped = line.strip()
|
|
204
|
+
if not stripped:
|
|
205
|
+
continue
|
|
206
|
+
try:
|
|
207
|
+
raw = json.loads(stripped)
|
|
208
|
+
wire = SessionMetaWire.model_validate(raw)
|
|
209
|
+
meta = _wire_to_meta(wire)
|
|
210
|
+
except Exception: # noqa: BLE001 - one bad line must not hide the rest
|
|
211
|
+
continue
|
|
212
|
+
existing = result.get(meta.session_id)
|
|
213
|
+
# The file is append-ordered, so on an equal timestamp the later
|
|
214
|
+
# line is the newer write. Coarse clocks (Windows ~15ms) tie often
|
|
215
|
+
# enough that a strict `>` silently discards fresh updates.
|
|
216
|
+
if existing is None or meta.updated_at >= existing.updated_at:
|
|
217
|
+
result[meta.session_id] = meta
|
|
218
|
+
return result
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""OS-backed single-writer ownership for a saved session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SessionBusy(RuntimeError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _record_holder(path: Path) -> None:
|
|
17
|
+
"""Note who holds the lease, beside the lock file, so a refusal can name them."""
|
|
18
|
+
command = " ".join(os.path.basename(part) if i == 0 else part
|
|
19
|
+
for i, part in enumerate(sys.argv[:3]))
|
|
20
|
+
try:
|
|
21
|
+
path.write_text(json.dumps({"pid": os.getpid(), "since": time.time(),
|
|
22
|
+
"command": command[:200]}), encoding="utf-8")
|
|
23
|
+
except OSError:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def describe_holder(path: Path) -> str:
|
|
28
|
+
"""`` It is held by process 1234 (rlm-agent worker run), since 14:02.`` or empty."""
|
|
29
|
+
try:
|
|
30
|
+
info = json.loads(path.read_text(encoding="utf-8"))
|
|
31
|
+
pid = int(info["pid"])
|
|
32
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
33
|
+
return ""
|
|
34
|
+
since = time.strftime("%H:%M", time.localtime(float(info.get("since") or 0)))
|
|
35
|
+
command = str(info.get("command") or "").strip()
|
|
36
|
+
return (f" It is held by process {pid}" + (f" ({command})" if command else "")
|
|
37
|
+
+ f", since {since}. Close that window or stop that worker to open it here.")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def acquire(directory: Path, session_id: str):
|
|
41
|
+
if Path(session_id).name != session_id or any(c in session_id for c in "/\\:"):
|
|
42
|
+
raise ValueError("invalid session ID")
|
|
43
|
+
locks = directory / ".leases"
|
|
44
|
+
locks.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
handle = (locks / f"{session_id}.lock").open("a+b")
|
|
46
|
+
try:
|
|
47
|
+
handle.seek(0)
|
|
48
|
+
try:
|
|
49
|
+
if os.name == "nt":
|
|
50
|
+
import msvcrt
|
|
51
|
+
|
|
52
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
53
|
+
else:
|
|
54
|
+
import fcntl
|
|
55
|
+
|
|
56
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
57
|
+
except OSError as exc:
|
|
58
|
+
raise SessionBusy(
|
|
59
|
+
f"Session {session_id} already has an active writer."
|
|
60
|
+
+ describe_holder(locks / f"{session_id}.holder")
|
|
61
|
+
) from exc
|
|
62
|
+
_record_holder(locks / f"{session_id}.holder")
|
|
63
|
+
return handle
|
|
64
|
+
except BaseException:
|
|
65
|
+
handle.close()
|
|
66
|
+
raise
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Discriminated record types for the append-only session log."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from time import time
|
|
6
|
+
from typing import Annotated, Literal
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
10
|
+
|
|
11
|
+
from rlmagent.harness.contracts.transcript import TranscriptEntry
|
|
12
|
+
from rlmagent.harness.contracts.values import JValue
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def mint_id() -> str:
|
|
16
|
+
"""Generate a hex identifier for a new session record."""
|
|
17
|
+
return uuid4().hex
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def now_epoch() -> float:
|
|
21
|
+
"""Capture the wall-clock time as a float epoch."""
|
|
22
|
+
return time()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
FORMAT = 1 # raise when saved records change shape; older files have no field and count as 1
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class EntryBase(BaseModel):
|
|
29
|
+
"""Shared header present on every record in the session log."""
|
|
30
|
+
|
|
31
|
+
model_config = ConfigDict(extra="forbid")
|
|
32
|
+
|
|
33
|
+
id: str = Field(default_factory=mint_id)
|
|
34
|
+
parent_id: str | None = None
|
|
35
|
+
timestamp: float = Field(default_factory=now_epoch)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TranscriptRecord(EntryBase):
|
|
39
|
+
"""Wraps a single harness transcript entry for persistence."""
|
|
40
|
+
|
|
41
|
+
type: Literal["message"] = "message"
|
|
42
|
+
message: TranscriptEntry
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ModelSwapRecord(EntryBase):
|
|
46
|
+
"""Records a switch to a different model identifier."""
|
|
47
|
+
|
|
48
|
+
type: Literal["model_change"] = "model_change"
|
|
49
|
+
model: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ReasoningLevelRecord(EntryBase):
|
|
53
|
+
"""Records an adjustment to the reasoning depth setting."""
|
|
54
|
+
|
|
55
|
+
type: Literal["thinking_level_change"] = "thinking_level_change"
|
|
56
|
+
thinking_level: str | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PruneRecord(EntryBase):
|
|
60
|
+
"""A condensed summary that stands in for pruned earlier records on replay."""
|
|
61
|
+
|
|
62
|
+
type: Literal["compaction"] = "compaction"
|
|
63
|
+
summary: str
|
|
64
|
+
replaces_entry_ids: list[str] = Field(default_factory=list)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ForkSummaryRecord(EntryBase):
|
|
68
|
+
"""Captures the synopsis of a diverged conversation branch."""
|
|
69
|
+
|
|
70
|
+
type: Literal["branch_summary"] = "branch_summary"
|
|
71
|
+
summary: str
|
|
72
|
+
branch_root_id: str | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TagRecord(EntryBase):
|
|
76
|
+
"""A user-assigned display name for the session."""
|
|
77
|
+
|
|
78
|
+
type: Literal["label"] = "label"
|
|
79
|
+
label: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TipRecord(EntryBase):
|
|
83
|
+
"""Points to the current head of the active conversation branch."""
|
|
84
|
+
|
|
85
|
+
type: Literal["leaf"] = "leaf"
|
|
86
|
+
entry_id: str | None = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class SessionMetaRecord(EntryBase):
|
|
90
|
+
"""Top-level metadata stamped when a session is first created."""
|
|
91
|
+
|
|
92
|
+
type: Literal["session_info"] = "session_info"
|
|
93
|
+
format: int = FORMAT
|
|
94
|
+
created_at: float = Field(default_factory=now_epoch)
|
|
95
|
+
cwd: str | None = None
|
|
96
|
+
title: str | None = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ExtensionRecord(EntryBase):
|
|
100
|
+
"""Opaque payload owned by an application-level plugin."""
|
|
101
|
+
|
|
102
|
+
type: Literal["custom"] = "custom"
|
|
103
|
+
namespace: str
|
|
104
|
+
data: dict[str, JValue] = Field(default_factory=dict)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
type SessionRecord = Annotated[
|
|
108
|
+
TranscriptRecord
|
|
109
|
+
| ModelSwapRecord
|
|
110
|
+
| ReasoningLevelRecord
|
|
111
|
+
| PruneRecord
|
|
112
|
+
| ForkSummaryRecord
|
|
113
|
+
| TagRecord
|
|
114
|
+
| TipRecord
|
|
115
|
+
| SessionMetaRecord
|
|
116
|
+
| ExtensionRecord,
|
|
117
|
+
Field(discriminator="type"),
|
|
118
|
+
]
|