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,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}"
|
rlmagent/agents/trace.py
ADDED
|
@@ -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)
|