rlmness-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. rlmagent/__init__.py +1 -0
  2. rlmagent/__main__.py +8 -0
  3. rlmagent/acp/__init__.py +1 -0
  4. rlmagent/acp/runtime.py +71 -0
  5. rlmagent/acp/server.py +598 -0
  6. rlmagent/acp/updates.py +128 -0
  7. rlmagent/agentnet.py +237 -0
  8. rlmagent/agents/__init__.py +1 -0
  9. rlmagent/agents/approval.py +90 -0
  10. rlmagent/agents/bridge.py +109 -0
  11. rlmagent/agents/budget.py +212 -0
  12. rlmagent/agents/child_handles.py +46 -0
  13. rlmagent/agents/child_mailbox.py +30 -0
  14. rlmagent/agents/kernel_api.py +156 -0
  15. rlmagent/agents/output.py +34 -0
  16. rlmagent/agents/patch.py +107 -0
  17. rlmagent/agents/prompts.py +156 -0
  18. rlmagent/agents/trace.py +290 -0
  19. rlmagent/agents/tree.py +682 -0
  20. rlmagent/agents/wasm_api.py +105 -0
  21. rlmagent/agents/workspace.py +357 -0
  22. rlmagent/autopilot.py +447 -0
  23. rlmagent/backend.py +946 -0
  24. rlmagent/backend_context.py +74 -0
  25. rlmagent/cli/__init__.py +5 -0
  26. rlmagent/cli/config_cmd.py +177 -0
  27. rlmagent/cli/health.py +156 -0
  28. rlmagent/cli/main.py +1194 -0
  29. rlmagent/cli/update.py +188 -0
  30. rlmagent/clipboard.py +63 -0
  31. rlmagent/config/__init__.py +37 -0
  32. rlmagent/config/config.py +297 -0
  33. rlmagent/config/loader.py +119 -0
  34. rlmagent/config/prompts.py +124 -0
  35. rlmagent/config/secure_input.py +213 -0
  36. rlmagent/config/store.py +275 -0
  37. rlmagent/context/__init__.py +47 -0
  38. rlmagent/context/budget.py +557 -0
  39. rlmagent/contract.py +311 -0
  40. rlmagent/contract_run.py +232 -0
  41. rlmagent/conversation.py +2547 -0
  42. rlmagent/cron.py +77 -0
  43. rlmagent/directives.py +893 -0
  44. rlmagent/discovery.py +356 -0
  45. rlmagent/execution_log.py +66 -0
  46. rlmagent/factory.py +189 -0
  47. rlmagent/factory_graph.py +413 -0
  48. rlmagent/harness/__init__.py +63 -0
  49. rlmagent/harness/contracts/__init__.py +1 -0
  50. rlmagent/harness/contracts/stream.py +129 -0
  51. rlmagent/harness/contracts/tooling.py +127 -0
  52. rlmagent/harness/contracts/transcript/__init__.py +68 -0
  53. rlmagent/harness/contracts/transcript/_base.py +29 -0
  54. rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
  55. rlmagent/harness/contracts/transcript/entries.py +180 -0
  56. rlmagent/harness/contracts/transcript/segments.py +64 -0
  57. rlmagent/harness/contracts/values.py +6 -0
  58. rlmagent/harness/driver.py +397 -0
  59. rlmagent/harness/engine.py +489 -0
  60. rlmagent/harness/mending.py +153 -0
  61. rlmagent/harness/provider/__init__.py +1 -0
  62. rlmagent/harness/provider/base.py +27 -0
  63. rlmagent/harness/provider/wire.py +189 -0
  64. rlmagent/harness/py.typed +0 -0
  65. rlmagent/harness/session/__init__.py +5 -0
  66. rlmagent/harness/session/index.py +218 -0
  67. rlmagent/harness/session/lease.py +66 -0
  68. rlmagent/harness/session/records.py +118 -0
  69. rlmagent/harness/session/replay.py +165 -0
  70. rlmagent/harness/session/store.py +155 -0
  71. rlmagent/harness/session/summary.py +46 -0
  72. rlmagent/hints.py +47 -0
  73. rlmagent/history.py +92 -0
  74. rlmagent/hooks/__init__.py +1 -0
  75. rlmagent/hooks/hooks.py +1 -0
  76. rlmagent/instructions.py +157 -0
  77. rlmagent/job_runner.py +117 -0
  78. rlmagent/jobs.py +365 -0
  79. rlmagent/kept_agents.py +397 -0
  80. rlmagent/kernel/__init__.py +5 -0
  81. rlmagent/kernel/session.py +205 -0
  82. rlmagent/kernel/wasm.py +169 -0
  83. rlmagent/logs.py +208 -0
  84. rlmagent/managed_release.py +368 -0
  85. rlmagent/mcp.py +411 -0
  86. rlmagent/mcp_oauth.py +213 -0
  87. rlmagent/model/__init__.py +29 -0
  88. rlmagent/model/_claude/__init__.py +1 -0
  89. rlmagent/model/_claude/courier.py +117 -0
  90. rlmagent/model/_claude/emitter.py +378 -0
  91. rlmagent/model/_oai/__init__.py +1 -0
  92. rlmagent/model/_oai/helpers.py +256 -0
  93. rlmagent/model/_oai/normalize.py +263 -0
  94. rlmagent/model/_oai/parsers.py +317 -0
  95. rlmagent/model/_oai/payloads.py +226 -0
  96. rlmagent/model/_oai/transport.py +127 -0
  97. rlmagent/model/claude.py +552 -0
  98. rlmagent/model/copilot.py +142 -0
  99. rlmagent/model/correlation.py +21 -0
  100. rlmagent/model/deadline.py +19 -0
  101. rlmagent/model/discovery.py +181 -0
  102. rlmagent/model/external/__init__.py +11 -0
  103. rlmagent/model/external/acp.py +202 -0
  104. rlmagent/model/external/acp_install.py +109 -0
  105. rlmagent/model/external/cli.py +248 -0
  106. rlmagent/model/external/textcalls.py +57 -0
  107. rlmagent/model/failover.py +129 -0
  108. rlmagent/model/google.py +199 -0
  109. rlmagent/model/limits.py +46 -0
  110. rlmagent/model/oai_compatible.py +414 -0
  111. rlmagent/model/preferences.py +56 -0
  112. rlmagent/model/py.typed +0 -0
  113. rlmagent/model/registered_oauth.py +195 -0
  114. rlmagent/model/scripted.py +55 -0
  115. rlmagent/model/settings.py +271 -0
  116. rlmagent/model/subscription.py +398 -0
  117. rlmagent/model/token_bounds.py +15 -0
  118. rlmagent/model/transport/__init__.py +1 -0
  119. rlmagent/model/transport/backoff.py +87 -0
  120. rlmagent/model/transport/client.py +87 -0
  121. rlmagent/model/transport/faults.py +65 -0
  122. rlmagent/model/transport/responses_socket.py +223 -0
  123. rlmagent/model/vertex.py +93 -0
  124. rlmagent/network.py +123 -0
  125. rlmagent/packages.py +246 -0
  126. rlmagent/planning.py +84 -0
  127. rlmagent/playbook/__init__.py +1 -0
  128. rlmagent/playbook/apply.py +106 -0
  129. rlmagent/playbook/digest.py +63 -0
  130. rlmagent/playbook/improver.py +272 -0
  131. rlmagent/playbook/plan.py +99 -0
  132. rlmagent/playbook/store.py +67 -0
  133. rlmagent/plugins.py +327 -0
  134. rlmagent/policy.py +105 -0
  135. rlmagent/prompt_refs.py +57 -0
  136. rlmagent/prompts.py +331 -0
  137. rlmagent/prune.py +60 -0
  138. rlmagent/py.typed +0 -0
  139. rlmagent/reasoning.py +135 -0
  140. rlmagent/refinement/__init__.py +1 -0
  141. rlmagent/refinement/controller.py +448 -0
  142. rlmagent/refinement/engine.py +220 -0
  143. rlmagent/refinement/evidence.py +131 -0
  144. rlmagent/refinement/scoped.py +90 -0
  145. rlmagent/refinement/store.py +193 -0
  146. rlmagent/refresh.py +202 -0
  147. rlmagent/routing.py +52 -0
  148. rlmagent/runtime.py +575 -0
  149. rlmagent/safety.py +267 -0
  150. rlmagent/session_html.py +57 -0
  151. rlmagent/session_import.py +52 -0
  152. rlmagent/session_share.py +55 -0
  153. rlmagent/sessions.py +249 -0
  154. rlmagent/shell_sandbox.py +88 -0
  155. rlmagent/skillset.py +320 -0
  156. rlmagent/steering.py +148 -0
  157. rlmagent/stops.py +62 -0
  158. rlmagent/subscriptions.py +219 -0
  159. rlmagent/tasks.py +98 -0
  160. rlmagent/toolhost.py +310 -0
  161. rlmagent/tools/__init__.py +56 -0
  162. rlmagent/tools/_shared.py +210 -0
  163. rlmagent/tools/files.py +462 -0
  164. rlmagent/tools/kernel.py +93 -0
  165. rlmagent/tools/patch_tool.py +68 -0
  166. rlmagent/tools/search.py +188 -0
  167. rlmagent/trust.py +383 -0
  168. rlmagent/tui/__init__.py +1 -0
  169. rlmagent/tui/adapter.py +506 -0
  170. rlmagent/tui/app.py +711 -0
  171. rlmagent/tui/approval_screen.py +57 -0
  172. rlmagent/tui/choice_screen.py +47 -0
  173. rlmagent/tui/completions.py +116 -0
  174. rlmagent/tui/components.py +25 -0
  175. rlmagent/tui/composer.py +40 -0
  176. rlmagent/tui/factory_view.py +86 -0
  177. rlmagent/tui/inspect.py +193 -0
  178. rlmagent/tui/messages.py +215 -0
  179. rlmagent/tui/model_screen.py +307 -0
  180. rlmagent/tui/prompt.py +98 -0
  181. rlmagent/tui/question_screen.py +52 -0
  182. rlmagent/tui/replay.py +141 -0
  183. rlmagent/tui/rlm_agent.tcss +405 -0
  184. rlmagent/tui/search_box.py +91 -0
  185. rlmagent/tui/sidebar.py +182 -0
  186. rlmagent/tui/status.py +49 -0
  187. rlmagent/tui/steps.py +283 -0
  188. rlmagent/tui/suggest.py +778 -0
  189. rlmagent/tui/transcript.py +172 -0
  190. rlmagent/tui/trust_dialog.py +58 -0
  191. rlmagent/tui/worker_attach.py +141 -0
  192. rlmagent/ui/__init__.py +1 -0
  193. rlmagent/ui/render.py +211 -0
  194. rlmagent/undo.py +109 -0
  195. rlmagent/utils/__init__.py +1 -0
  196. rlmagent/websearch.py +75 -0
  197. rlmagent/worker.py +1275 -0
  198. rlmagent/worker_service.py +203 -0
  199. rlmness_cli-0.2.0.dist-info/METADATA +882 -0
  200. rlmness_cli-0.2.0.dist-info/RECORD +203 -0
  201. rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
  202. rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
  203. rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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"]