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/kernel/wasm.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""The python tool's kernel inside a WebAssembly sandbox (Pyodide, through rlmness).
|
|
2
|
+
|
|
3
|
+
Same surface as KernelSession, so the python tool and the agent tree do not care
|
|
4
|
+
which one they have. The sandbox has an empty filesystem, no network and no host
|
|
5
|
+
environment; host functions (bridges) are the only way out.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import contextlib
|
|
12
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
13
|
+
|
|
14
|
+
from rlmagent.kernel.session import CellResult, clip
|
|
15
|
+
|
|
16
|
+
Bridge = Callable[..., Awaitable[object]]
|
|
17
|
+
|
|
18
|
+
LOST = ("[The sandbox was replaced ({why}): variables from before are gone. "
|
|
19
|
+
"Re-run any setup you still need.]")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def available() -> bool:
|
|
23
|
+
try:
|
|
24
|
+
from rlmness.wasm_runtime import wasm_available
|
|
25
|
+
except ImportError:
|
|
26
|
+
return False
|
|
27
|
+
return wasm_available()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class WasmKernel:
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
cwd: str,
|
|
34
|
+
bridges: Mapping[str, Bridge],
|
|
35
|
+
startup_code: str = "",
|
|
36
|
+
timeout: float = 300.0,
|
|
37
|
+
on_final: Callable[[object], Awaitable[None]] | None = None,
|
|
38
|
+
output_limit: int = 20_000,
|
|
39
|
+
restore: bool = False,
|
|
40
|
+
) -> None:
|
|
41
|
+
self.cwd = cwd
|
|
42
|
+
self.env: dict[str, str] = {}
|
|
43
|
+
self.bridges = dict(bridges)
|
|
44
|
+
self.startup_code = startup_code
|
|
45
|
+
self.timeout = timeout
|
|
46
|
+
self.on_final = on_final
|
|
47
|
+
self.output_limit = output_limit
|
|
48
|
+
self.restore = restore
|
|
49
|
+
self._checkpoint: dict = {}
|
|
50
|
+
self._owned: set[str] | None = None
|
|
51
|
+
self._recovery_note = ""
|
|
52
|
+
self._runtime = None
|
|
53
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
54
|
+
self._lock = asyncio.Lock()
|
|
55
|
+
|
|
56
|
+
@contextlib.contextmanager
|
|
57
|
+
def waiting_outside(self):
|
|
58
|
+
# The sandbox does not time a cell out while one of its bridges is running.
|
|
59
|
+
yield
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def started(self) -> bool:
|
|
63
|
+
return self._runtime is not None
|
|
64
|
+
|
|
65
|
+
def _blocking(self, bridge: Bridge) -> Callable[..., object]:
|
|
66
|
+
"""rlmness serves bridges on worker threads; the work belongs on this agent's loop."""
|
|
67
|
+
def call(*args, **kwargs):
|
|
68
|
+
future = asyncio.run_coroutine_threadsafe(bridge(*args, **kwargs), self._loop)
|
|
69
|
+
return future.result()
|
|
70
|
+
return call
|
|
71
|
+
|
|
72
|
+
async def _start(self) -> str | None:
|
|
73
|
+
from rlmness.wasm_runtime import WasmRuntime
|
|
74
|
+
|
|
75
|
+
self._loop = asyncio.get_running_loop()
|
|
76
|
+
bridges = {name: self._blocking(fn) for name, fn in self.bridges.items()}
|
|
77
|
+
options = {"session": self._checkpoint} if self.restore else {}
|
|
78
|
+
# Loading Pyodide on a cold host has a separate deadline from user code.
|
|
79
|
+
self._runtime = await asyncio.to_thread(
|
|
80
|
+
WasmRuntime, None, bridges, max(30.0, self.timeout), **options
|
|
81
|
+
)
|
|
82
|
+
if self.startup_code:
|
|
83
|
+
outcome = await asyncio.to_thread(self._runtime.execute, self.startup_code)
|
|
84
|
+
if outcome.error:
|
|
85
|
+
await self._drop()
|
|
86
|
+
return outcome.error
|
|
87
|
+
if self.restore:
|
|
88
|
+
if self._owned is None:
|
|
89
|
+
initial = await asyncio.to_thread(self._runtime.sweep)
|
|
90
|
+
self._owned = {name for entries in initial.values() for name in entries}
|
|
91
|
+
self._owned.update(("CONTEXT", "PROMPT", "FINAL", "commit"))
|
|
92
|
+
if self._checkpoint:
|
|
93
|
+
failed = self._runtime.restore_failed
|
|
94
|
+
saved = set().union(*(self._checkpoint.get(k, {}) for k in
|
|
95
|
+
("variables", "functions", "modules")))
|
|
96
|
+
restored = sorted(saved - failed)
|
|
97
|
+
missing = sorted(set(self._checkpoint.get("dropped", {})) | failed)
|
|
98
|
+
self._recovery_note = ("[Sandbox checkpoint restored: "
|
|
99
|
+
+ (", ".join(restored) or "no values")
|
|
100
|
+
+ "; unavailable: " + (", ".join(missing) or "none")
|
|
101
|
+
+ ". Changes in the interrupted cell were not recovered.]\n")
|
|
102
|
+
self._runtime.timeout = self.timeout
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
async def _save_checkpoint(self, code: str) -> None:
|
|
106
|
+
state = await asyncio.to_thread(self._runtime.sweep, code)
|
|
107
|
+
if state:
|
|
108
|
+
self._checkpoint = {kind: {name: value for name, value in entries.items()
|
|
109
|
+
if name not in self._owned}
|
|
110
|
+
for kind, entries in state.items()}
|
|
111
|
+
|
|
112
|
+
def _lost(self, why: str) -> str:
|
|
113
|
+
if not self.restore:
|
|
114
|
+
return LOST.format(why=why)
|
|
115
|
+
return (f"[The sandbox was replaced ({why}). The next cell will restore the last "
|
|
116
|
+
"completed checkpoint; changes from the interrupted cell are lost. "
|
|
117
|
+
"External side effects may already have happened.]")
|
|
118
|
+
|
|
119
|
+
async def _drop(self) -> None:
|
|
120
|
+
runtime, self._runtime = self._runtime, None
|
|
121
|
+
if runtime is not None:
|
|
122
|
+
await asyncio.to_thread(runtime.close)
|
|
123
|
+
|
|
124
|
+
async def run(self, code: str, timeout: float | None = None) -> CellResult:
|
|
125
|
+
from rlmness.runtime import CellTimeout, RuntimeGone
|
|
126
|
+
|
|
127
|
+
async with self._lock:
|
|
128
|
+
if self._runtime is None:
|
|
129
|
+
failure = await self._start()
|
|
130
|
+
if failure:
|
|
131
|
+
return CellResult(output="", error=f"sandbox start-up failed:\n{failure}")
|
|
132
|
+
cell_timeout = self.timeout if timeout is None else timeout
|
|
133
|
+
self._runtime.timeout = cell_timeout
|
|
134
|
+
try:
|
|
135
|
+
outcome = await asyncio.to_thread(self._runtime.execute, code)
|
|
136
|
+
except CellTimeout:
|
|
137
|
+
from rlmness.wasm_runtime import WasmRuntime, reset_host
|
|
138
|
+
|
|
139
|
+
self._runtime = None
|
|
140
|
+
if not getattr(WasmRuntime, "ISOLATED", False):
|
|
141
|
+
await asyncio.to_thread(reset_host)
|
|
142
|
+
return CellResult(output=self._lost(why=f"no answer within {cell_timeout:.0f}s"),
|
|
143
|
+
timed_out=True, restarted=True)
|
|
144
|
+
except RuntimeGone:
|
|
145
|
+
await self._drop()
|
|
146
|
+
return CellResult(output=self._lost(why="it stopped"), restarted=True)
|
|
147
|
+
finally:
|
|
148
|
+
if self._runtime is not None:
|
|
149
|
+
self._runtime.timeout = self.timeout
|
|
150
|
+
if self.restore and not outcome.error:
|
|
151
|
+
await self._save_checkpoint(code)
|
|
152
|
+
recovery_note, self._recovery_note = self._recovery_note, ""
|
|
153
|
+
output = recovery_note + outcome.stdout
|
|
154
|
+
if outcome.final_given and self.on_final is not None:
|
|
155
|
+
await self.on_final(outcome.final)
|
|
156
|
+
output += "FINAL recorded.\n"
|
|
157
|
+
return CellResult(output=clip(output, self.output_limit),
|
|
158
|
+
error=clip(outcome.error, self.output_limit) if outcome.error else None)
|
|
159
|
+
|
|
160
|
+
async def interrupt(self) -> None:
|
|
161
|
+
# Pyodide cannot be interrupted from outside; stopping means replacing it.
|
|
162
|
+
await self._drop()
|
|
163
|
+
|
|
164
|
+
async def kill(self) -> None:
|
|
165
|
+
await self._drop()
|
|
166
|
+
|
|
167
|
+
async def shutdown(self) -> None:
|
|
168
|
+
await self._drop()
|
|
169
|
+
self._checkpoint = {}
|
rlmagent/logs.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Run logging: trajectory, raw events, and stdout captured under ``.logs/``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from rlmagent.refresh import _scrub
|
|
13
|
+
from rlmagent.harness.contracts.stream import (
|
|
14
|
+
AgentEvent,
|
|
15
|
+
MessageEndEvent,
|
|
16
|
+
RunEndEvent,
|
|
17
|
+
RunStartEvent,
|
|
18
|
+
ToolRunEndEvent,
|
|
19
|
+
ToolRunStartEvent,
|
|
20
|
+
)
|
|
21
|
+
from rlmagent.harness.contracts.transcript import HumanEntry, ModelEntry
|
|
22
|
+
|
|
23
|
+
_DIR_ENV = "RLM_AGENT_LOG_DIR"
|
|
24
|
+
_DISABLE_ENV = "RLM_AGENT_NO_LOGS"
|
|
25
|
+
_DEFAULT_DIR = ".logs"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _now() -> str:
|
|
29
|
+
return datetime.now(UTC).isoformat(timespec="milliseconds")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def logs_enabled() -> bool:
|
|
33
|
+
"""Whether run logging is switched on."""
|
|
34
|
+
return os.environ.get(_DISABLE_ENV, "").strip().lower() not in {"1", "true", "yes"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def log_root(cwd: str | Path | None = None) -> Path:
|
|
38
|
+
"""Directory holding run logs — ``RLM_AGENT_LOG_DIR`` or ``<cwd>/.logs``."""
|
|
39
|
+
override = os.environ.get(_DIR_ENV)
|
|
40
|
+
if override:
|
|
41
|
+
return Path(override)
|
|
42
|
+
return Path(cwd or os.getcwd()) / _DEFAULT_DIR
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _clean(value: Any) -> Any:
|
|
46
|
+
"""Recursively scrub strings inside a JSON-compatible value."""
|
|
47
|
+
if isinstance(value, str):
|
|
48
|
+
return _scrub(value)
|
|
49
|
+
if isinstance(value, dict):
|
|
50
|
+
return {k: _clean(v) for k, v in value.items()}
|
|
51
|
+
if isinstance(value, list):
|
|
52
|
+
return [_clean(v) for v in value]
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(slots=True)
|
|
57
|
+
class RunLogger:
|
|
58
|
+
"""Writes one session's logs into ``<root>/<session_id>/``."""
|
|
59
|
+
|
|
60
|
+
session_id: str
|
|
61
|
+
root: Path
|
|
62
|
+
provider: str = ""
|
|
63
|
+
model: str = ""
|
|
64
|
+
steps: list[dict[str, Any]] = field(default_factory=list)
|
|
65
|
+
#: Optional zero-arg callable returning the session's usage stats.
|
|
66
|
+
stats_source: Any = None
|
|
67
|
+
|
|
68
|
+
# -- construction -------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def create(
|
|
72
|
+
cls,
|
|
73
|
+
session_id: str,
|
|
74
|
+
*,
|
|
75
|
+
provider: str = "",
|
|
76
|
+
model: str = "",
|
|
77
|
+
cwd: str | Path | None = None,
|
|
78
|
+
root: Path | None = None,
|
|
79
|
+
) -> RunLogger | None:
|
|
80
|
+
"""Prepare the log directory, or return ``None`` when disabled."""
|
|
81
|
+
if not logs_enabled():
|
|
82
|
+
return None
|
|
83
|
+
base = (root or log_root(cwd)) / session_id
|
|
84
|
+
try:
|
|
85
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
# The logs sit in the user's project; keep them out of its git status.
|
|
87
|
+
ignore = base.parent / ".gitignore"
|
|
88
|
+
if not ignore.exists():
|
|
89
|
+
ignore.write_text("*\n", encoding="utf-8")
|
|
90
|
+
except OSError:
|
|
91
|
+
return None
|
|
92
|
+
logger = cls(session_id=session_id, root=base, provider=provider, model=model)
|
|
93
|
+
logger.write_line(f"session {session_id} started ({provider} · {model})")
|
|
94
|
+
return logger
|
|
95
|
+
|
|
96
|
+
# -- paths --------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def trajectory_path(self) -> Path:
|
|
100
|
+
return self.root / "trajectory.json"
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def events_path(self) -> Path:
|
|
104
|
+
return self.root / "events.jsonl"
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def stdout_path(self) -> Path:
|
|
108
|
+
return self.root / "stdout.log"
|
|
109
|
+
|
|
110
|
+
# -- writing ------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def write_line(self, text: str) -> None:
|
|
113
|
+
"""Append a timestamped line to ``stdout.log``."""
|
|
114
|
+
self._append(self.stdout_path, f"{_now()} {_scrub(text)}\n")
|
|
115
|
+
|
|
116
|
+
def record_event(self, event: AgentEvent) -> None:
|
|
117
|
+
"""Append one event to ``events.jsonl`` and update the trajectory."""
|
|
118
|
+
try:
|
|
119
|
+
payload = _clean(event.model_dump(mode="json", by_alias=True))
|
|
120
|
+
except Exception: # noqa: BLE001 - logging must never break a run
|
|
121
|
+
return
|
|
122
|
+
self._append(self.events_path, json.dumps(payload, default=str) + "\n")
|
|
123
|
+
self._track(event)
|
|
124
|
+
|
|
125
|
+
def finish(self, stats: Any = None) -> None:
|
|
126
|
+
"""Write ``trajectory.json``."""
|
|
127
|
+
if stats is None and self.stats_source is not None:
|
|
128
|
+
try:
|
|
129
|
+
stats = self.stats_source()
|
|
130
|
+
except Exception: # noqa: BLE001
|
|
131
|
+
stats = None
|
|
132
|
+
doc: dict[str, Any] = {
|
|
133
|
+
"session_id": self.session_id,
|
|
134
|
+
"provider": self.provider,
|
|
135
|
+
"model": self.model,
|
|
136
|
+
"finished_at": _now(),
|
|
137
|
+
"steps": self.steps,
|
|
138
|
+
}
|
|
139
|
+
if stats is not None:
|
|
140
|
+
doc["usage"] = {
|
|
141
|
+
"turns": getattr(stats, "turn_count", 0),
|
|
142
|
+
"messages": getattr(stats, "message_count", 0),
|
|
143
|
+
"input_tokens": getattr(stats, "total_input_tokens", 0),
|
|
144
|
+
"output_tokens": getattr(stats, "total_output_tokens", 0),
|
|
145
|
+
"cost_usd": getattr(stats, "total_cost_usd", 0.0),
|
|
146
|
+
"context_tokens": getattr(stats, "estimated_context_tokens", 0),
|
|
147
|
+
}
|
|
148
|
+
# Scrub the whole document, not just the steps built here — any
|
|
149
|
+
# caller-supplied content must be covered too.
|
|
150
|
+
self._write(
|
|
151
|
+
self.trajectory_path, json.dumps(_clean(doc), indent=2, default=str),
|
|
152
|
+
)
|
|
153
|
+
self.write_line("session finished")
|
|
154
|
+
|
|
155
|
+
# -- internals ----------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def _track(self, event: AgentEvent) -> None:
|
|
158
|
+
"""Fold an event into the human-readable trajectory."""
|
|
159
|
+
if isinstance(event, RunStartEvent):
|
|
160
|
+
self.write_line("run started")
|
|
161
|
+
elif isinstance(event, ToolRunStartEvent):
|
|
162
|
+
self.steps.append({
|
|
163
|
+
"at": _now(), "kind": "tool_call",
|
|
164
|
+
"tool": event.tool_name,
|
|
165
|
+
"args": _clean(dict(event.args)),
|
|
166
|
+
})
|
|
167
|
+
self.write_line(f"tool {event.tool_name} started")
|
|
168
|
+
elif isinstance(event, ToolRunEndEvent):
|
|
169
|
+
self.steps.append({
|
|
170
|
+
"at": _now(), "kind": "tool_result",
|
|
171
|
+
"tool": event.tool_name,
|
|
172
|
+
"is_error": event.is_error,
|
|
173
|
+
"output": _scrub(event.result.text)[:4000],
|
|
174
|
+
})
|
|
175
|
+
self.write_line(
|
|
176
|
+
f"tool {event.tool_name} {'failed' if event.is_error else 'ok'}"
|
|
177
|
+
)
|
|
178
|
+
elif isinstance(event, MessageEndEvent):
|
|
179
|
+
message = event.message
|
|
180
|
+
if isinstance(message, ModelEntry) and message.text:
|
|
181
|
+
self.steps.append({
|
|
182
|
+
"at": _now(), "kind": "assistant",
|
|
183
|
+
"text": _scrub(message.text),
|
|
184
|
+
"stop_reason": message.stop_reason,
|
|
185
|
+
})
|
|
186
|
+
elif isinstance(message, HumanEntry) and message.text:
|
|
187
|
+
self.steps.append({
|
|
188
|
+
"at": _now(), "kind": "prompt", "text": _scrub(message.text),
|
|
189
|
+
})
|
|
190
|
+
elif isinstance(event, RunEndEvent):
|
|
191
|
+
self.write_line("run ended")
|
|
192
|
+
self.finish()
|
|
193
|
+
|
|
194
|
+
def _append(self, path: Path, text: str) -> None:
|
|
195
|
+
try:
|
|
196
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
197
|
+
fh.write(text)
|
|
198
|
+
except OSError:
|
|
199
|
+
pass # logging is best effort — never break the run
|
|
200
|
+
|
|
201
|
+
def _write(self, path: Path, text: str) -> None:
|
|
202
|
+
try:
|
|
203
|
+
path.write_text(text, encoding="utf-8")
|
|
204
|
+
except OSError:
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
__all__ = ["RunLogger", "log_root", "logs_enabled"]
|