agent-runtime-py 0.2.1__tar.gz → 0.2.2__tar.gz
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.
- {agent_runtime_py-0.2.1 → agent_runtime_py-0.2.2}/CHANGELOG.md +7 -0
- {agent_runtime_py-0.2.1 → agent_runtime_py-0.2.2}/PKG-INFO +1 -1
- {agent_runtime_py-0.2.1 → agent_runtime_py-0.2.2}/pyproject.toml +1 -1
- {agent_runtime_py-0.2.1 → agent_runtime_py-0.2.2}/src/agent_runtime/claude.py +21 -8
- {agent_runtime_py-0.2.1 → agent_runtime_py-0.2.2}/src/agent_runtime/worker.py +14 -1
- {agent_runtime_py-0.2.1 → agent_runtime_py-0.2.2}/tests/test_claude_runtime.py +100 -0
- {agent_runtime_py-0.2.1 → agent_runtime_py-0.2.2}/uv.lock +1 -1
- {agent_runtime_py-0.2.1 → agent_runtime_py-0.2.2}/.github/workflows/ci.yml +0 -0
- {agent_runtime_py-0.2.1 → agent_runtime_py-0.2.2}/.github/workflows/release.yml +0 -0
- {agent_runtime_py-0.2.1 → agent_runtime_py-0.2.2}/.gitignore +0 -0
- {agent_runtime_py-0.2.1 → agent_runtime_py-0.2.2}/LICENSE +0 -0
- {agent_runtime_py-0.2.1 → agent_runtime_py-0.2.2}/README.md +0 -0
- {agent_runtime_py-0.2.1 → agent_runtime_py-0.2.2}/src/agent_runtime/__init__.py +0 -0
- {agent_runtime_py-0.2.1 → agent_runtime_py-0.2.2}/src/agent_runtime/bridge.py +0 -0
- {agent_runtime_py-0.2.1 → agent_runtime_py-0.2.2}/src/agent_runtime/hooks.py +0 -0
- {agent_runtime_py-0.2.1 → agent_runtime_py-0.2.2}/src/agent_runtime/monitor_worker.py +0 -0
- {agent_runtime_py-0.2.1 → agent_runtime_py-0.2.2}/src/agent_runtime/monitors.py +0 -0
- {agent_runtime_py-0.2.1 → agent_runtime_py-0.2.2}/src/agent_runtime/operations.py +0 -0
- {agent_runtime_py-0.2.1 → agent_runtime_py-0.2.2}/src/agent_runtime/sessions.py +0 -0
- {agent_runtime_py-0.2.1 → agent_runtime_py-0.2.2}/src/agent_runtime/store.py +0 -0
- {agent_runtime_py-0.2.1 → agent_runtime_py-0.2.2}/tests/test_hooks.py +0 -0
- {agent_runtime_py-0.2.1 → agent_runtime_py-0.2.2}/tests/test_monitors.py +0 -0
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.2
|
|
4
|
+
|
|
5
|
+
- Add `LaunchOptions.effort` (`low`, `medium`, `high`, `xhigh` or `max`), passed to native Claude.
|
|
6
|
+
- A worker that fails adds `native_stderr` to its result: the last lines (at most 20, each at most 500 characters) that native Claude wrote to stderr since its last result. They are often the reason of a failed start, where the SDK's error only says to check stderr. They are not redacted; a consumer decides whether to show them.
|
|
7
|
+
- Fix: `LaunchOptions.claude` and the `claude` found on PATH are used as given (made absolute), not as their link target. A version manager's shim (a link to the manager) was resolved to the manager, which then did not run Claude. A path that is not a file, also a directory, now raises `ValueError`; a missing path raised `FileNotFoundError` before.
|
|
8
|
+
- A repeated launch ID no longer compares the path of the Claude executable, so a launch that 0.2.1 recorded can be repeated.
|
|
9
|
+
|
|
3
10
|
## 0.2.1
|
|
4
11
|
|
|
5
12
|
- Fix: a Claude input that native Claude folded into a turn started by something else (a peer message, a background task) never settled, because that turn's result carries the other origin. The worker then took no further input and never exited on idle. A result now settles the active input when the input's echo arrived before it.
|
|
@@ -33,6 +33,7 @@ class LaunchOptions:
|
|
|
33
33
|
permission_mode: str = "default"
|
|
34
34
|
allowed_tools: tuple[str, ...] = ()
|
|
35
35
|
model: str | None = None
|
|
36
|
+
effort: str | None = None
|
|
36
37
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
37
38
|
|
|
38
39
|
def configuration(self) -> dict[str, Any]:
|
|
@@ -48,18 +49,34 @@ class LaunchOptions:
|
|
|
48
49
|
"auto",
|
|
49
50
|
}:
|
|
50
51
|
raise ValueError("unknown Claude permission mode")
|
|
52
|
+
if self.effort is not None and self.effort not in ("low", "medium", "high", "xhigh", "max"):
|
|
53
|
+
raise ValueError("unknown Claude effort level")
|
|
51
54
|
cwd = self.cwd.expanduser().resolve(strict=True)
|
|
52
55
|
if not cwd.is_dir():
|
|
53
56
|
raise ValueError("cwd must be a directory")
|
|
54
57
|
executable = str(self.claude) if self.claude else shutil.which("claude")
|
|
55
58
|
if not executable:
|
|
56
59
|
raise ValueError("Claude Code executable not found")
|
|
60
|
+
# The path as given, not its link target: a version manager's shim is a link to the
|
|
61
|
+
# manager, which chooses the program by the name it was called with.
|
|
62
|
+
claude = Path(executable).expanduser().absolute()
|
|
63
|
+
if not claude.is_file():
|
|
64
|
+
raise ValueError(f"Claude Code executable not found: {claude}")
|
|
57
65
|
value = asdict(self)
|
|
58
66
|
value.pop("environment")
|
|
59
|
-
|
|
67
|
+
if self.effort is None:
|
|
68
|
+
value.pop("effort") # as a record of a version without the field
|
|
69
|
+
value.update(cwd=str(cwd), claude=str(claude))
|
|
60
70
|
return json.loads(json.dumps(value))
|
|
61
71
|
|
|
62
72
|
|
|
73
|
+
def launch_identity(configuration: dict[str, Any]) -> dict[str, Any]:
|
|
74
|
+
"""The part of a launch configuration that a repeated launch ID must repeat. The path of
|
|
75
|
+
the Claude executable is not part of it: its spelling changes with the installation and
|
|
76
|
+
between versions of this package (0.2.1 stored the link's target)."""
|
|
77
|
+
return {key: value for key, value in configuration.items() if key != "claude"}
|
|
78
|
+
|
|
79
|
+
|
|
63
80
|
def process_alive(value: dict[str, Any]) -> bool:
|
|
64
81
|
"""PID reuse must never attach a client to an unrelated process."""
|
|
65
82
|
if not value.get("pid") or not value.get("process_created_at"):
|
|
@@ -145,15 +162,11 @@ class ClaudeRuntime:
|
|
|
145
162
|
}
|
|
146
163
|
if not self.store.create_execution(value):
|
|
147
164
|
existing = self.inspect(execution_id)
|
|
148
|
-
for key in (
|
|
149
|
-
"initial_message_id",
|
|
150
|
-
"initial_digest",
|
|
151
|
-
"resume",
|
|
152
|
-
"fork",
|
|
153
|
-
"configuration",
|
|
154
|
-
):
|
|
165
|
+
for key in ("initial_message_id", "initial_digest", "resume", "fork"):
|
|
155
166
|
if existing[key] != value[key]:
|
|
156
167
|
raise ValueError(f"launch ID already used with different {key}")
|
|
168
|
+
if launch_identity(existing["configuration"]) != launch_identity(configuration):
|
|
169
|
+
raise ValueError("launch ID already used with different configuration")
|
|
157
170
|
return existing
|
|
158
171
|
self.store.accept(session_id, execution_id, message_id, text)
|
|
159
172
|
environment = {**os.environ, **options.environment}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import collections
|
|
6
7
|
import logging
|
|
7
8
|
import os
|
|
8
9
|
import signal
|
|
@@ -45,6 +46,14 @@ class Worker:
|
|
|
45
46
|
self.last_activity = time.monotonic()
|
|
46
47
|
self.background_tasks: set[str] = set()
|
|
47
48
|
self.presentation_started = False
|
|
49
|
+
# The last lines that native Claude wrote to stderr since its last result. When the
|
|
50
|
+
# worker fails, they go into its result apart from the error: they are often the
|
|
51
|
+
# reason (the SDK's error only says to check stderr), but not always.
|
|
52
|
+
self.stderr: collections.deque[str] = collections.deque(maxlen=20)
|
|
53
|
+
|
|
54
|
+
def native_stderr(self, line: str) -> None:
|
|
55
|
+
log.debug("claude: %s", line)
|
|
56
|
+
self.stderr.append(line[:500])
|
|
48
57
|
|
|
49
58
|
def emit(self, kind: str, **fields: Any) -> None:
|
|
50
59
|
self.store.append(self.execution_id, self.session_id, {"type": kind, **fields})
|
|
@@ -123,6 +132,7 @@ class Worker:
|
|
|
123
132
|
elif isinstance(message, ResultMessage):
|
|
124
133
|
if message.session_id != self.session_id:
|
|
125
134
|
raise RuntimeError("native Claude returned a different session ID")
|
|
135
|
+
self.stderr.clear() # what it wrote before a result is not about a later failure
|
|
126
136
|
human = message.origin is None or message.origin.get("kind") == "human"
|
|
127
137
|
# Native Claude can fold an input into a turn that something else started
|
|
128
138
|
# (a peer message, a background task); that turn's result carries the other
|
|
@@ -189,6 +199,7 @@ class Worker:
|
|
|
189
199
|
permission_mode=policy["permission_mode"],
|
|
190
200
|
allowed_tools=policy["allowed_tools"],
|
|
191
201
|
model=policy["model"],
|
|
202
|
+
effort=policy.get("effort"), # absent in the record of an older version
|
|
192
203
|
setting_sources=["user", "project", "local"],
|
|
193
204
|
system_prompt={
|
|
194
205
|
"type": "preset",
|
|
@@ -196,7 +207,7 @@ class Worker:
|
|
|
196
207
|
"append": policy["instructions"] or "",
|
|
197
208
|
},
|
|
198
209
|
can_use_tool=self.permission,
|
|
199
|
-
stderr=
|
|
210
|
+
stderr=self.native_stderr,
|
|
200
211
|
extra_args={"replay-user-messages": None},
|
|
201
212
|
session_id=self.session_id
|
|
202
213
|
if not self.execution["resume"] or self.execution["fork"]
|
|
@@ -277,6 +288,8 @@ class Worker:
|
|
|
277
288
|
except Exception as error:
|
|
278
289
|
log.exception("Claude worker failed")
|
|
279
290
|
result = {"status": "failed", "error": str(error)}
|
|
291
|
+
if self.stderr:
|
|
292
|
+
result["native_stderr"] = list(self.stderr)
|
|
280
293
|
finally:
|
|
281
294
|
self.stopping.set()
|
|
282
295
|
try:
|
|
@@ -21,6 +21,15 @@ import json, os, sys
|
|
|
21
21
|
if "--version" in sys.argv:
|
|
22
22
|
print("2.1.281 (Claude Code)")
|
|
23
23
|
raise SystemExit(0)
|
|
24
|
+
if os.environ.get("FAKE_CLAUDE_ARGV"):
|
|
25
|
+
with open(os.environ["FAKE_CLAUDE_ARGV"], "w") as log:
|
|
26
|
+
json.dump(sys.argv, log)
|
|
27
|
+
if os.environ.get("FAKE_CLAUDE_FAILS"):
|
|
28
|
+
for number in range(30):
|
|
29
|
+
print(f"line {number} " + "x" * 600, file=sys.stderr, flush=True)
|
|
30
|
+
print("Invalid API key - Please run /login", file=sys.stderr, flush=True)
|
|
31
|
+
raise SystemExit(1)
|
|
32
|
+
print("warning: a line at the start", file=sys.stderr, flush=True)
|
|
24
33
|
def emit(value):
|
|
25
34
|
print(json.dumps(value), flush=True)
|
|
26
35
|
session = next(a.split("=", 1)[1] for a in sys.argv if a.startswith(("--session-id=", "--resume=")))
|
|
@@ -51,6 +60,12 @@ for line in sys.stdin:
|
|
|
51
60
|
result("answer to both", PEER)
|
|
52
61
|
continue
|
|
53
62
|
emit({"type":"user","uuid":message["uuid"],"message":{"role":"user","content":text},"origin":{"kind":"human"}})
|
|
63
|
+
if text == "wrong-session":
|
|
64
|
+
# A failure of the worker's own check, late in a session, with no stderr line.
|
|
65
|
+
emit({"type":"result", "subtype":"success", "is_error":False, "duration_ms":1,
|
|
66
|
+
"duration_api_ms":1, "num_turns":1, "session_id":"another-session",
|
|
67
|
+
"result":"x", "origin":{"kind":"human"}, "usage":{}})
|
|
68
|
+
continue
|
|
54
69
|
if text == "then-a-peer-turn":
|
|
55
70
|
# The input's own turn ends; a peer message then starts a turn that works
|
|
56
71
|
# in silence for longer than the idle timeout.
|
|
@@ -243,3 +258,88 @@ async def test_a_turn_for_another_origin_is_not_ended_by_the_idle_timeout(tmp_pa
|
|
|
243
258
|
assert settled[1]["message"] == "peer turn done"
|
|
244
259
|
finally:
|
|
245
260
|
await runtime.stop(eid)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
async def test_effort_reaches_native_claude_and_a_link_to_it_is_not_resolved(tmp_path, native):
|
|
264
|
+
link = tmp_path / "shim"
|
|
265
|
+
link.symlink_to(native)
|
|
266
|
+
opts = LaunchOptions(
|
|
267
|
+
cwd=tmp_path,
|
|
268
|
+
claude=link,
|
|
269
|
+
effort="high",
|
|
270
|
+
idle_timeout_seconds=0,
|
|
271
|
+
environment={
|
|
272
|
+
"FAKE_CLAUDE_LOG": str(tmp_path / "inputs.jsonl"),
|
|
273
|
+
"FAKE_CLAUDE_ARGV": str(tmp_path / "argv.json"),
|
|
274
|
+
},
|
|
275
|
+
)
|
|
276
|
+
assert opts.configuration()["claude"] == str(link)
|
|
277
|
+
with pytest.raises(ValueError, match="effort"):
|
|
278
|
+
LaunchOptions(cwd=tmp_path, claude=native, effort="extreme").configuration()
|
|
279
|
+
with pytest.raises(ValueError, match="not found"):
|
|
280
|
+
LaunchOptions(cwd=tmp_path, claude=tmp_path / "missing").configuration()
|
|
281
|
+
runtime = ClaudeRuntime(tmp_path / "runtime")
|
|
282
|
+
value = await runtime.start("hello", message_id="m1", options=opts, launch_id="effort")
|
|
283
|
+
try:
|
|
284
|
+
await wait_for(
|
|
285
|
+
lambda: runtime.store.receipt(value["session_id"], "m1")["status"] == "delivered"
|
|
286
|
+
)
|
|
287
|
+
argv = json.loads((tmp_path / "argv.json").read_text())
|
|
288
|
+
assert argv[0] == str(link) and argv[argv.index("--effort") + 1] == "high"
|
|
289
|
+
finally:
|
|
290
|
+
await runtime.stop(value["execution_id"])
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
async def test_a_native_claude_that_fails_at_its_start_reports_what_it_wrote(tmp_path, native):
|
|
294
|
+
runtime = ClaudeRuntime(tmp_path / "runtime")
|
|
295
|
+
opts = options(tmp_path, native)
|
|
296
|
+
opts.environment["FAKE_CLAUDE_FAILS"] = "1"
|
|
297
|
+
value = await runtime.start("hello", message_id="m1", options=opts, launch_id="fails")
|
|
298
|
+
result = await wait_for(lambda: runtime.inspect(value["execution_id"])["result"])
|
|
299
|
+
assert result["status"] == "failed" and "Invalid API key" not in result["error"]
|
|
300
|
+
# The last 20 lines, each cut to 500 characters, apart from the error.
|
|
301
|
+
lines = result["native_stderr"]
|
|
302
|
+
assert len(lines) == 20 and lines[-1] == "Invalid API key - Please run /login"
|
|
303
|
+
assert all(len(line) <= 500 for line in lines) and lines[0].startswith("line 11 ")
|
|
304
|
+
assert runtime.store.receipt(value["session_id"], "m1")["status"] == "rejected"
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
async def test_stderr_from_before_a_result_is_not_reported_with_a_later_failure(tmp_path, native):
|
|
308
|
+
runtime = ClaudeRuntime(tmp_path / "runtime")
|
|
309
|
+
opts = options(tmp_path, native)
|
|
310
|
+
value = await runtime.start("hello", message_id="m1", options=opts, launch_id="late")
|
|
311
|
+
sid, eid = value["session_id"], value["execution_id"]
|
|
312
|
+
await wait_for(lambda: runtime.store.receipt(sid, "m1")["status"] == "delivered")
|
|
313
|
+
await runtime.submit(sid, "wrong-session", message_id="m2", options=opts, resume=False)
|
|
314
|
+
result = await wait_for(lambda: runtime.inspect(eid)["result"])
|
|
315
|
+
assert result["status"] == "failed" and "different session ID" in result["error"]
|
|
316
|
+
assert "native_stderr" not in result # the warning at the start came before the first result
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
async def test_a_launch_id_from_an_older_version_can_be_repeated(tmp_path, native):
|
|
320
|
+
"""0.2.1 stored no effort and the link target of the executable; a repeat of that launch
|
|
321
|
+
by this version must find the same execution."""
|
|
322
|
+
link = tmp_path / "shim"
|
|
323
|
+
link.symlink_to(native)
|
|
324
|
+
runtime = ClaudeRuntime(tmp_path / "runtime")
|
|
325
|
+
opts = LaunchOptions(
|
|
326
|
+
cwd=tmp_path,
|
|
327
|
+
claude=link,
|
|
328
|
+
idle_timeout_seconds=0,
|
|
329
|
+
environment={"FAKE_CLAUDE_LOG": str(tmp_path / "inputs.jsonl")},
|
|
330
|
+
)
|
|
331
|
+
assert "effort" not in opts.configuration()
|
|
332
|
+
first = await runtime.start("hello", message_id="m1", options=opts, launch_id="old")
|
|
333
|
+
eid = first["execution_id"]
|
|
334
|
+
try:
|
|
335
|
+
stored = dict(runtime.store.execution(eid)["configuration"], claude=str(native.resolve()))
|
|
336
|
+
runtime.store.update(eid, configuration=stored)
|
|
337
|
+
again = await runtime.start("hello", message_id="m1", options=opts, launch_id="old")
|
|
338
|
+
assert again["execution_id"] == eid
|
|
339
|
+
with pytest.raises(ValueError, match="configuration"):
|
|
340
|
+
changed = LaunchOptions(
|
|
341
|
+
cwd=tmp_path, claude=link, effort="high", idle_timeout_seconds=0
|
|
342
|
+
)
|
|
343
|
+
await runtime.start("hello", message_id="m1", options=changed, launch_id="old")
|
|
344
|
+
finally:
|
|
345
|
+
await runtime.stop(eid)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|