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,156 @@
1
+ """What each agent is told about its kernel, its sub-agents and its limits."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from rlmagent.agents.tree import AgentNode
8
+
9
+ KERNEL_GUIDE = """## Your kernel
10
+ The python tool runs code in one kernel that stays alive for the whole task: \
11
+ variables, imports and functions you define remain available in later calls. \
12
+ Its working directory is the project root. Run shell commands with a leading `!` \
13
+ or with subprocess. Print summaries (lengths, counts, a few lines) rather than \
14
+ whole files or large objects; long output is cut to its start and end."""
15
+
16
+ SANDBOX_GUIDE = """## Your kernel
17
+ The python tool runs code in one sandboxed kernel (Python compiled to WebAssembly) that stays alive for the whole task: variables, imports and functions you define remain available in later calls. The sandbox has its own empty filesystem; `open()` does not see the project.{network} Reach the project only through these functions (await them):
18
+
19
+ text = await read_file("src/app.py")
20
+ part = await read_file("src/app.py", 100, 160) # only lines 100 to 160
21
+ paths = await list_files("**/*.py")
22
+ hits = await search_files(r"def main", glob="*.py") # [{"path", "line", "text"}]
23
+ await write_file("src/app.py", text) # asks the user first
24
+ await edit_file("src/app.py", old, new) # old must occur exactly once
25
+ await apply_patch(unified_diff) # several edits or files, all or none; asks first
26
+ r = await run("python -m pytest -q") # {"exit": 0, "output": "..."}; asks first
27
+ answer = await ask_user("Which one?", ["a", "b"]) # only when you truly cannot decide
28
+
29
+ Paths are relative to the project root. Run tests, builds and installs with run(); it executes on the user's machine. A refused write or command raises RuntimeError: tell the user what you wanted to do instead of retrying. Print summaries rather than whole files; long output is cut to its start and end."""
30
+
31
+ DELEGATION_GUIDE = """## Sub-agents
32
+ Inside the kernel you can hand work to sub-agents, which are agents like you \
33
+ with their own kernel and the same tools:
34
+
35
+ answer = await rlm("task for the sub-agent", context)
36
+ answers = await gather_rlm([("task A", data_a), ("task B", data_b)])
37
+
38
+ - `context` can be any JSON-compatible value. The sub-agent receives it as the \
39
+ variable CONTEXT in its own kernel, so large data never has to pass through a \
40
+ message. Slice or filter it first: send each sub-agent only what its task needs. \
41
+ If you hand on most of your own context barely reduced, the sub-agent is asked to \
42
+ check the hand-off first and may refuse to start; then narrow the data and try again.
43
+ - Each call returns the value the sub-agent passed to FINAL, as a normal Python \
44
+ object you can keep working with.
45
+ - Delegate when pieces are independent (the same check across many files, \
46
+ several separate questions) or when reading everything yourself would crowd \
47
+ out the rest of the task. Do small things yourself; a sub-agent costs a full \
48
+ agent run.
49
+ - If the user asks you to use sub-agents, you must call rlm, gather_rlm or spawn; \
50
+ do not do the work yourself instead.
51
+ - gather_rlm runs its jobs at the same time and returns results in order. If \
52
+ one fails, the others are stopped and the call raises.
53
+
54
+ To keep working while a sub-agent runs, start it in the background:
55
+
56
+ h = await spawn("run the test suite and report failures", data)
57
+ ... # carry on with other cells
58
+ report = await h.result() # waits; h.result(timeout=10) raises TimeoutError if not done
59
+ await h.status() # starting, running, idle, done, failed or cancelled
60
+ await h.cancel() # stops it and everything it started
61
+ await h.messages() # notes it left for you
62
+ hs = await children() # every sub-agent you started
63
+ await name_child(h, "reviewer") # name a child under this parent
64
+ h = await find_child("reviewer") # look up its handle
65
+ info = await child_info(h) # status and recorded usage
66
+ await steer_child(h, "check login first") # guide its next model request
67
+
68
+ - Use spawn for long work you do not need yet or may want to stop; use \
69
+ gather_rlm for independent pieces you need all of now.
70
+ - spawn(task, data, keep=True) keeps the sub-agent after its FINAL, idle, so \
71
+ h.send("next instruction") can give it more work; h.result() then returns its \
72
+ newest FINAL.
73
+ - rlm and spawn take model=, e.g. model="cli:codex" or "cli:claude-code?model=sonnet", \
74
+ to run that sub-agent on another model; only when the user asks for one.
75
+ - When you finish, sub-agents you started are stopped; collect what you need \
76
+ first."""
77
+
78
+ LEAF_NOTE = "Sub-agents are not available to you: rlm, gather_rlm and spawn are not defined in your kernel. Do this task yourself."
79
+
80
+ CHILD_ROLE = """## Your role
81
+ You were started by another agent to do one task. Its data, if any, is in the \
82
+ variable CONTEXT in your kernel. When you are done, call FINAL(value) in the \
83
+ python tool with the result it asked for; that value is returned to it directly. \
84
+ Call FINAL exactly once. To report progress before then, await tell_parent("note")."""
85
+
86
+ RESUMED_NOTICE = (
87
+ "This session was resumed. The kernel is new: variables, imports and functions "
88
+ "from earlier in the conversation are gone. Rebuild anything you still need."
89
+ )
90
+
91
+ COMPACTION_NOTE = "The kernel is still running: variables defined before this summary are still available."
92
+
93
+
94
+ PLAYBOOK_GUIDE = """## Playbook
95
+ You keep a playbook of rules, memories, skills and sub-agent roles across \
96
+ sessions. When something is worth keeping (a correction from the user, a \
97
+ mistake you repeated, a procedure you did twice, a lasting fact), call \
98
+ `await improve("what to keep")` in the python tool; the playbook is updated \
99
+ when the turn ends. Without `shared=True` it is kept for this session only; \
100
+ pass `shared=True` when the user says to remember it always, in every future \
101
+ session, or for every project. `await playbook()` lists entries."""
102
+
103
+
104
+ ONLINE = (" It can reach the internet: use httpx (async, `async with httpx.AsyncClient()`) "
105
+ "or `pyodide.http.pyfetch`; requests does not work here. Install pure-Python packages "
106
+ "with `await micropip.install(...)`; numpy, pandas and other compiled packages with "
107
+ "`await pyodide_js.loadPackage(...)`.")
108
+ OFFLINE = " It has no network and no pip."
109
+
110
+
111
+ def kernel_guide(sandboxed: bool, network: bool = False) -> str:
112
+ if not sandboxed:
113
+ return KERNEL_GUIDE
114
+ return SANDBOX_GUIDE.replace("{network}", ONLINE if network else OFFLINE)
115
+
116
+
117
+ def root_system(base: str, can_delegate: bool, playbook: bool = False,
118
+ sandboxed: bool = False, network: bool = False) -> str:
119
+ guide = kernel_guide(sandboxed, network)
120
+ parts = [base, guide, DELEGATION_GUIDE if can_delegate else LEAF_NOTE]
121
+ if playbook:
122
+ parts.append(PLAYBOOK_GUIDE)
123
+ return "\n\n".join(parts)
124
+
125
+
126
+ def child_system(base: str, node: AgentNode, can_delegate: bool, sandboxed: bool = False,
127
+ network: bool = False) -> str:
128
+ guide = kernel_guide(sandboxed, network)
129
+ parts = [base, guide, CHILD_ROLE, DELEGATION_GUIDE if can_delegate else LEAF_NOTE]
130
+ return "\n\n".join(parts)
131
+
132
+
133
+ def context_preview(value: object, limit: int = 600) -> str:
134
+ if value is None:
135
+ return "CONTEXT is None (no data was passed)."
136
+ if isinstance(value, str):
137
+ kind = f"a string of {len(value):,} characters"
138
+ elif isinstance(value, (list, tuple)):
139
+ kind = f"a list of {len(value):,} items"
140
+ elif isinstance(value, dict):
141
+ kind = f"a dict with keys {list(value)[:20]}"
142
+ else:
143
+ kind = f"a {type(value).__name__}"
144
+ text = value if isinstance(value, str) else json.dumps(value, default=repr)
145
+ head = text[:limit] + (" ..." if len(text) > limit else "")
146
+ return f"CONTEXT is {kind}. It begins:\n{head}"
147
+
148
+
149
+ RETURN_REMINDER = (
150
+ "Deliver your answer by calling FINAL(value) in the python tool. "
151
+ "A plain text reply is not returned to the agent that asked."
152
+ )
153
+
154
+
155
+ def child_first_message(node: AgentNode) -> str:
156
+ return f"{node.task}\n\n{context_preview(node.context)}\n\n{RETURN_REMINDER}"
@@ -0,0 +1,290 @@
1
+ """A record of every model call in an agent tree, and how the calls relate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import time
8
+ import uuid
9
+ from contextvars import ContextVar
10
+ from pathlib import Path
11
+
12
+ from rlmagent.agents.budget import REFUSED
13
+ from rlmagent.harness.provider.wire import StreamCloseEvent, StreamFaultEvent
14
+
15
+ request_id: ContextVar[str | None] = ContextVar("model_request_id", default=None)
16
+ attempt_observer: ContextVar[object] = ContextVar("model_attempt_observer", default=None)
17
+
18
+
19
+ def model_attempt(**fields):
20
+ observer = attempt_observer.get()
21
+ if observer is not None:
22
+ observer(**fields)
23
+
24
+
25
+ def correlate_headers(headers: dict[str, str]) -> dict[str, str]:
26
+ call = request_id.get()
27
+ if call is None:
28
+ return headers
29
+ result = {key: value for key, value in headers.items()
30
+ if key.lower() != "x-rlmagent-request-id"}
31
+ result["X-Rlmagent-Request-Id"] = call
32
+ return result
33
+
34
+
35
+ async def correlated(stream, call: str | None, tracer=None):
36
+ """Give each provider read the same ID without leaking it to other streams."""
37
+ iterator = aiter(stream)
38
+ try:
39
+ while True:
40
+ token = request_id.set(call)
41
+ observer = attempt_observer.set(
42
+ (lambda **fields: tracer._write("model_attempt", request=call, **fields))
43
+ if tracer is not None else None)
44
+ try:
45
+ event = await anext(iterator)
46
+ except StopAsyncIteration:
47
+ return
48
+ finally:
49
+ request_id.reset(token)
50
+ attempt_observer.reset(observer)
51
+ yield event
52
+ finally:
53
+ if close := getattr(iterator, "aclose", None):
54
+ await close()
55
+
56
+
57
+ class Tracer:
58
+ def __init__(self, path: Path | str | None = None, contract: dict | None = None) -> None:
59
+ self.path = Path(path) if path else None
60
+ self.records: list[dict] = []
61
+ self._last: dict[str, str] = {}
62
+ self._pending: dict[str, list[tuple[str, str]]] = {}
63
+ self._compacted: set[str] = set()
64
+ self._returned: set[str] = set()
65
+ self._totals: dict[str, dict] = {}
66
+ self._open: dict[str, tuple] = {}
67
+ self._historical_calls: dict[str, dict] = {}
68
+ self._child_calls: dict[str, str] = {}
69
+ if self.path is not None:
70
+ self.path.parent.mkdir(parents=True, exist_ok=True)
71
+ self.path.write_text("", encoding="utf-8")
72
+ self._write("run", contract=contract, started=time.time())
73
+
74
+ def _write(self, kind: str, **fields) -> None:
75
+ record = {"kind": kind, **fields}
76
+ self.records.append(record)
77
+ if self.path is not None:
78
+ with self.path.open("a", encoding="utf-8") as f:
79
+ f.write(json.dumps(record, default=str) + "\n")
80
+
81
+ def persist(self, path: Path) -> None:
82
+ """Append this runtime's request ledger to the saved session."""
83
+ if self.path is not None:
84
+ return
85
+ path.parent.mkdir(parents=True, exist_ok=True)
86
+ if path.exists():
87
+ for line in path.read_text(encoding="utf-8").splitlines():
88
+ if line.strip():
89
+ record = json.loads(line)
90
+ if not isinstance(record, dict):
91
+ raise ValueError("Saved request ledger contains an invalid record")
92
+ if record.get("kind") == "call":
93
+ self._remember_call(self._historical_calls, record)
94
+ with path.open("a", encoding="utf-8") as output:
95
+ for record in self.records:
96
+ output.write(json.dumps(record, default=str) + "\n")
97
+ self.path = path
98
+
99
+ @staticmethod
100
+ def _remember_call(calls, record):
101
+ identity = record.get("id")
102
+ if not isinstance(identity, str) or not identity:
103
+ raise ValueError("Saved request ledger has a call without an identity")
104
+ if identity in calls and calls[identity] != record:
105
+ raise ValueError("Saved request ledger has conflicting records for one call")
106
+ calls[identity] = record
107
+
108
+ def lifetime_usage(self):
109
+ """Idempotent persisted-plus-current usage, separate from admission counters."""
110
+ calls = dict(self._historical_calls)
111
+ for record in self.records:
112
+ if record.get("kind") == "call":
113
+ self._remember_call(calls, record)
114
+ fields = ("input", "output", "cache_read", "cache_write", "reasoning", "cost")
115
+ agents, incomplete, unreported, unpriced = {}, 0, 0, 0
116
+ for record in calls.values():
117
+ agent = record.get("agent")
118
+ if not isinstance(agent, str):
119
+ raise ValueError("Saved request ledger has an invalid agent")
120
+ totals = agents.setdefault(agent, dict.fromkeys(("calls", *fields), 0))
121
+ totals["calls"] += 1
122
+ incomplete += any(field not in record for field in fields)
123
+ unreported += not record.get("usage_reported", False)
124
+ unpriced += record.get("cost", 0) == 0
125
+ for field in fields:
126
+ value = record.get(field, 0)
127
+ if type(value) not in (int, float) or not math.isfinite(value) or value < 0:
128
+ raise ValueError("Saved request ledger has invalid usage")
129
+ totals[field] += value
130
+ return {
131
+ "agents": agents,
132
+ "totals": {field: sum(agent[field] for agent in agents.values())
133
+ for field in ("calls", *fields)},
134
+ "coverage": {"incompleteUsageRecords": incomplete,
135
+ "unreportedUsageRecords": unreported, "unpricedRecords": unpriced},
136
+ }
137
+
138
+ def child_usage(self, child: str, *, compatibility: bool = False) -> dict:
139
+ fields = ("input", "output", "cache_read", "cache_write", "reasoning", "cost")
140
+ calls = {} if compatibility else dict(self._historical_calls)
141
+ for record in self.records:
142
+ if record.get("kind") == "call":
143
+ self._remember_call(calls, record)
144
+ selected = [record for record in calls.values()
145
+ if record.get("agent" if compatibility else "child") == child]
146
+ return {"calls": len(selected),
147
+ "unreported": sum(not record.get("usage_reported") for record in selected),
148
+ "unpriced": sum(record.get("cost", 0) == 0 for record in selected),
149
+ **{field: sum(record.get(field, 0) for record in selected) for field in fields}}
150
+
151
+ def begin(self, agent: str, model: str, *, child: str | None = None) -> str:
152
+ call = uuid.uuid4().hex
153
+ if child is not None:
154
+ self._child_calls[call] = child
155
+ previous = self._last.get(agent)
156
+ compacted = agent in self._compacted
157
+ pending = self._pending.pop(agent, [])
158
+ links = [(source, how) for source, how in pending]
159
+ if previous is not None:
160
+ links.insert(0, (previous, "compact" if compacted else "next"))
161
+ self._compacted.discard(agent)
162
+ self._last[agent] = call
163
+ self._open[call] = (agent, model, time.time(), previous, pending, compacted, links)
164
+ return call
165
+
166
+ def drop(self, call: str) -> None:
167
+ """Forget a call that was never sent (refused by the budget)."""
168
+ self._child_calls.pop(call, None)
169
+ agent, _, _, previous, pending, compacted, _ = self._open.pop(call)
170
+ if previous is None:
171
+ self._last.pop(agent, None)
172
+ else:
173
+ self._last[agent] = previous
174
+ if pending:
175
+ self._pending.setdefault(agent, [])[:0] = pending
176
+ if compacted:
177
+ self._compacted.add(agent)
178
+
179
+ def end(self, call: str, usage, stop: str | None) -> None:
180
+ agent, model, started, _, _, _, links = self._open.pop(call)
181
+ child = self._child_calls.pop(call, None)
182
+ for source, how in links:
183
+ self._write("link", **{"from": source, "to": call, "how": how})
184
+ self._totals.setdefault(agent, {"calls": 0, "cost": 0.0, "input": 0, "output": 0})
185
+ cost = usage.cost.total if usage is not None else 0.0
186
+ tokens_in = usage.input if usage is not None else 0
187
+ tokens_out = usage.output if usage is not None else 0
188
+ t = self._totals[agent]
189
+ t["calls"] += 1
190
+ t["cost"] += cost
191
+ t["input"] += tokens_in
192
+ t["output"] += tokens_out
193
+ for name in ("cache_read", "cache_write", "reasoning"):
194
+ t[name] = t.get(name, 0) + ((getattr(usage, name, 0) or 0) if usage is not None else 0)
195
+ self._write("call", id=call, agent=agent, model=model, stop=stop, cost=cost,
196
+ input=tokens_in, output=tokens_out, usage_reported=usage is not None,
197
+ cache_read=(usage.cache_read or 0) if usage is not None else 0,
198
+ cache_write=(usage.cache_write or 0) if usage is not None else 0,
199
+ reasoning=(usage.reasoning or 0) if usage is not None else 0,
200
+ seconds=round(time.time() - started, 3),
201
+ **({"child": child} if child is not None else {}))
202
+
203
+ def spawned(self, parent: str, child: str) -> None:
204
+ if parent in self._last:
205
+ self._pending.setdefault(child, []).append((self._last[parent], "spawn"))
206
+
207
+ def returned(self, parent: str, child: str) -> None:
208
+ last = self._last.get(child)
209
+ if last is not None and last not in self._returned:
210
+ self._returned.add(last)
211
+ self._pending.setdefault(parent, []).append((last, "return"))
212
+
213
+ def compacted(self, agent: str) -> None:
214
+ self._compacted.add(agent)
215
+
216
+ def finish(self, agents: list[dict], stopped_by: str | None, complete: bool = True) -> dict:
217
+ for agent in agents:
218
+ self._write("agent", **agent)
219
+ summary = {
220
+ "calls": sum(t["calls"] for t in self._totals.values()),
221
+ "cost": sum(t["cost"] for t in self._totals.values()),
222
+ "input": sum(t["input"] for t in self._totals.values()),
223
+ "output": sum(t["output"] for t in self._totals.values()),
224
+ "agents": self._totals,
225
+ "agents_started": max(len(agents) - 1, 0),
226
+ "stopped_by": stopped_by,
227
+ "complete": complete,
228
+ }
229
+ self._write("summary", **summary)
230
+ return summary
231
+
232
+
233
+ class TracedProvider:
234
+ """One agent's view of the tree's provider: every call is traced as that agent's."""
235
+
236
+ follows_tree = True
237
+
238
+ def __init__(self, tree, agent: str) -> None:
239
+ self._tree = tree
240
+ self._agent = agent
241
+
242
+ def __getattr__(self, name):
243
+ return getattr(self._tree.provider, name)
244
+
245
+ async def stream_response(self, *, model, **kwargs):
246
+ limit = getattr(getattr(self._tree, "policy", None), "agent_max_tokens", None)
247
+ if limit is not None and self._tree.tracer._totals.get(self._agent, {}).get("output", 0) >= limit:
248
+ from rlmagent.harness.contracts.transcript import ModelEntry
249
+
250
+ yield StreamFaultEvent(reason="error", error=ModelEntry(model=model, stop_reason="error",
251
+ error_message=f"agent {self._agent} reached its limit of {limit} completion tokens"))
252
+ return
253
+ refinement = getattr(self._tree, "refinement", None)
254
+ if refinement is not None:
255
+ kwargs["system"] = refinement.filter_system(kwargs["system"])
256
+ tracer = self._tree.tracer
257
+ call = tracer.begin(self._agent, model)
258
+ session = getattr(self._tree, "session", None)
259
+ if session is None:
260
+ session = getattr(self._tree, "_session_of", {}).get(self._agent)
261
+ ledger = getattr(session, "steering", None)
262
+ pending = []
263
+ if ledger is not None:
264
+ from rlmagent.harness.contracts.transcript import HumanEntry
265
+
266
+ text, pending = ledger.context(self._agent, root=self._agent == self._tree.root_id())
267
+ if text:
268
+ kwargs["messages"] = [*kwargs["messages"], HumanEntry(content="Steering instructions:\n" + text)]
269
+ usage, stop, refused = None, None, False
270
+ from rlmagent.model.deadline import bounded_stream
271
+ try:
272
+ async for event in correlated(
273
+ bounded_stream(self._tree.provider.stream_response(model=model, **kwargs),
274
+ model, getattr(self._tree, "request_timeout", 300.0)), call, tracer
275
+ ):
276
+ if isinstance(event, StreamCloseEvent):
277
+ usage, stop = event.message.usage, event.reason
278
+ if ledger is not None:
279
+ ledger.consumed(pending, self._agent, call)
280
+ if pending:
281
+ tracer._write("steering", agent=self._agent, request=call, messages=pending)
282
+ elif isinstance(event, StreamFaultEvent):
283
+ usage, stop = event.error.usage, event.reason
284
+ refused = (event.error.error_message or "").startswith(REFUSED)
285
+ yield event
286
+ finally:
287
+ if refused:
288
+ tracer.drop(call)
289
+ else:
290
+ tracer.end(call, usage, stop)