agent-runtime-py 0.2.0__tar.gz → 0.2.1__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/CHANGELOG.md +14 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/PKG-INFO +1 -1
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/pyproject.toml +1 -1
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/src/agent_runtime/claude.py +3 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/src/agent_runtime/worker.py +22 -1
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/tests/test_claude_runtime.py +81 -2
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/uv.lock +3 -3
- agent_runtime_py-0.2.0/CHANGELOG.md +0 -8
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/.github/workflows/ci.yml +0 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/.github/workflows/release.yml +0 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/.gitignore +0 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/LICENSE +0 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/README.md +0 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/src/agent_runtime/__init__.py +0 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/src/agent_runtime/bridge.py +0 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/src/agent_runtime/hooks.py +0 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/src/agent_runtime/monitor_worker.py +0 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/src/agent_runtime/monitors.py +0 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/src/agent_runtime/operations.py +0 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/src/agent_runtime/sessions.py +0 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/src/agent_runtime/store.py +0 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/tests/test_hooks.py +0 -0
- {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/tests/test_monitors.py +0 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.1
|
|
4
|
+
|
|
5
|
+
- 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.
|
|
6
|
+
- Fix: a turn that native Claude runs for another origin (a peer message) counts as work: the worker reports `turn.started` when that message arrives, not at the first assistant text, and does not exit on idle while the turn runs.
|
|
7
|
+
- Fix: the Claude worker starts with `-P`, so the session's working folder is not on its import path.
|
|
8
|
+
|
|
9
|
+
## 0.2.0
|
|
10
|
+
|
|
11
|
+
- Breaking: requires typed-agent-hooks 0.3. Run the hooks with `agent_runtime.hooks.app.main()` and the host name (`codex` or `claude`) as the only argument; the host hook file sets the timeout. The `agent-runtime-hooks` console script is removed.
|
|
12
|
+
- Breaking: the Claude Agent SDK moves to the `claude` extra. `ClaudeRuntime` needs `agent-runtime-py[claude]`.
|
|
13
|
+
- Fix: a monitor worker whose command exceeded the output limit never exited and ignored SIGTERM.
|
|
14
|
+
- Fix: a Claude SessionStart, which carries no permission mode, kept the mode empty; it now keeps the mode that the same live Claude process reported.
|
|
@@ -177,6 +177,9 @@ class ClaudeRuntime:
|
|
|
177
177
|
child = subprocess.Popen(
|
|
178
178
|
[
|
|
179
179
|
sys.executable,
|
|
180
|
+
# The worker's cwd is the session's folder, which the agent
|
|
181
|
+
# writes; it must not be on the worker's import path.
|
|
182
|
+
"-P",
|
|
180
183
|
"-m",
|
|
181
184
|
"agent_runtime.worker",
|
|
182
185
|
str(self.store.root),
|
|
@@ -100,6 +100,17 @@ class Worker:
|
|
|
100
100
|
if isinstance(message, UserMessage) and message.uuid:
|
|
101
101
|
if admitted := self.store.admit(self.execution_id, message.uuid):
|
|
102
102
|
self.emit("input.delivered", message_id=admitted, native_uuid=message.uuid)
|
|
103
|
+
if self.active and self.active["id"] == admitted:
|
|
104
|
+
self.active["acknowledged"] = True
|
|
105
|
+
elif (
|
|
106
|
+
(message.origin or {}).get("kind", "human") != "human"
|
|
107
|
+
and message.parent_tool_use_id is None
|
|
108
|
+
and not self.presentation_started
|
|
109
|
+
):
|
|
110
|
+
# A message from another origin (a peer session, a background task)
|
|
111
|
+
# starts a turn that no input of this runtime asked for.
|
|
112
|
+
self.emit("turn.started", origin="native")
|
|
113
|
+
self.presentation_started = True
|
|
103
114
|
elif isinstance(message, AssistantMessage) and message.parent_tool_use_id is None:
|
|
104
115
|
if not self.presentation_started:
|
|
105
116
|
self.emit("turn.started", origin="native")
|
|
@@ -113,7 +124,15 @@ class Worker:
|
|
|
113
124
|
if message.session_id != self.session_id:
|
|
114
125
|
raise RuntimeError("native Claude returned a different session ID")
|
|
115
126
|
human = message.origin is None or message.origin.get("kind") == "human"
|
|
116
|
-
|
|
127
|
+
# Native Claude can fold an input into a turn that something else started
|
|
128
|
+
# (a peer message, a background task); that turn's result carries the other
|
|
129
|
+
# origin. The turn that follows the input's echo is the one that consumed it.
|
|
130
|
+
# A non-human result before the echo belongs to an earlier turn.
|
|
131
|
+
active = (
|
|
132
|
+
self.active
|
|
133
|
+
if self.active and (human or self.active.get("acknowledged"))
|
|
134
|
+
else None
|
|
135
|
+
)
|
|
117
136
|
status = "failed" if message.is_error else "completed"
|
|
118
137
|
if message.terminal_reason in {"aborted_streaming", "aborted_tools"}:
|
|
119
138
|
status = "interrupted"
|
|
@@ -243,6 +262,8 @@ class Worker:
|
|
|
243
262
|
if (
|
|
244
263
|
timeout
|
|
245
264
|
and self.active is None
|
|
265
|
+
# A turn that native Claude runs for another origin is work too.
|
|
266
|
+
and not self.presentation_started
|
|
246
267
|
and not self.background_tasks
|
|
247
268
|
and time.monotonic() - self.last_activity >= timeout
|
|
248
269
|
and not any(
|
|
@@ -24,10 +24,11 @@ if "--version" in sys.argv:
|
|
|
24
24
|
def emit(value):
|
|
25
25
|
print(json.dumps(value), flush=True)
|
|
26
26
|
session = next(a.split("=", 1)[1] for a in sys.argv if a.startswith(("--session-id=", "--resume=")))
|
|
27
|
-
|
|
27
|
+
PEER = {"kind":"peer","from":"unknown"}
|
|
28
|
+
def result(text, origin={"kind":"human"}):
|
|
28
29
|
emit({"type":"result", "subtype":"success", "is_error":False,
|
|
29
30
|
"duration_ms":1,"duration_api_ms":1,"num_turns":1,"session_id":session,
|
|
30
|
-
"result":text,"origin":
|
|
31
|
+
"result":text,"origin":origin,"usage":{}})
|
|
31
32
|
pending = None
|
|
32
33
|
for line in sys.stdin:
|
|
33
34
|
message = json.loads(line)
|
|
@@ -40,7 +41,25 @@ for line in sys.stdin:
|
|
|
40
41
|
log.write(json.dumps({"text": text, "uuid":message["uuid"], "session": session}) + "\n")
|
|
41
42
|
if text == "crash-before-echo":
|
|
42
43
|
raise SystemExit(23)
|
|
44
|
+
if text == "folded-into-peer-turn":
|
|
45
|
+
# Native Claude, observed 2026-11-10: a peer message's turn ends, a second peer
|
|
46
|
+
# message starts a turn, the queued human input joins that turn, and the turn's
|
|
47
|
+
# result carries the peer origin.
|
|
48
|
+
result("first peer turn", PEER)
|
|
49
|
+
emit({"type":"user","uuid":"peer-message","message":{"role":"user","content":"peer"},"origin":PEER})
|
|
50
|
+
emit({"type":"user","uuid":message["uuid"],"message":{"role":"user","content":text},"origin":{"kind":"human"}})
|
|
51
|
+
result("answer to both", PEER)
|
|
52
|
+
continue
|
|
43
53
|
emit({"type":"user","uuid":message["uuid"],"message":{"role":"user","content":text},"origin":{"kind":"human"}})
|
|
54
|
+
if text == "then-a-peer-turn":
|
|
55
|
+
# The input's own turn ends; a peer message then starts a turn that works
|
|
56
|
+
# in silence for longer than the idle timeout.
|
|
57
|
+
import time
|
|
58
|
+
result("echo: " + text)
|
|
59
|
+
emit({"type":"user","uuid":"peer-message","message":{"role":"user","content":"peer"},"origin":PEER})
|
|
60
|
+
time.sleep(1.5)
|
|
61
|
+
result("peer turn done", PEER)
|
|
62
|
+
continue
|
|
44
63
|
if text == "permission":
|
|
45
64
|
pending = text
|
|
46
65
|
emit({"type":"control_request","request_id":"permission-1",
|
|
@@ -164,3 +183,63 @@ async def test_permission_waits_for_durable_explicit_answer(tmp_path, native):
|
|
|
164
183
|
runtime.store.answer(eid, requests[0]["id"], {"decision": "allow"})
|
|
165
184
|
finally:
|
|
166
185
|
await runtime.stop(eid)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def test_input_folded_into_a_peer_turn_settles_with_that_turn(tmp_path, native):
|
|
189
|
+
runtime = ClaudeRuntime(tmp_path / "runtime")
|
|
190
|
+
opts = options(tmp_path, native)
|
|
191
|
+
value = await runtime.start(
|
|
192
|
+
"folded-into-peer-turn", message_id="m1", options=opts, launch_id="folded"
|
|
193
|
+
)
|
|
194
|
+
sid, eid = value["session_id"], value["execution_id"]
|
|
195
|
+
|
|
196
|
+
def settled():
|
|
197
|
+
return [
|
|
198
|
+
e["event"] for e in runtime.store.events(sid) if e["event"]["type"] == "work.settled"
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
await wait_for(lambda: len(settled()) >= 2)
|
|
203
|
+
# The first result came before the input's echo, so it is not the input's result.
|
|
204
|
+
assert [event["message_ids"] for event in settled()] == [[], ["m1"]]
|
|
205
|
+
assert settled()[1]["message"] == "answer to both"
|
|
206
|
+
# The worker is free again: it takes the next input.
|
|
207
|
+
await runtime.submit(sid, "next", message_id="m2", options=opts, resume=False)
|
|
208
|
+
await wait_for(lambda: len(settled()) >= 3)
|
|
209
|
+
assert settled()[2]["message_ids"] == ["m2"]
|
|
210
|
+
finally:
|
|
211
|
+
await runtime.stop(eid)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
async def test_a_turn_for_another_origin_is_not_ended_by_the_idle_timeout(tmp_path, native):
|
|
215
|
+
runtime = ClaudeRuntime(tmp_path / "runtime")
|
|
216
|
+
opts = LaunchOptions(
|
|
217
|
+
cwd=tmp_path,
|
|
218
|
+
claude=native,
|
|
219
|
+
idle_timeout_seconds=0.5,
|
|
220
|
+
environment={"FAKE_CLAUDE_LOG": str(tmp_path / "inputs.jsonl")},
|
|
221
|
+
)
|
|
222
|
+
value = await runtime.start("then-a-peer-turn", message_id="m1", options=opts, launch_id="peer")
|
|
223
|
+
sid, eid = value["session_id"], value["execution_id"]
|
|
224
|
+
try:
|
|
225
|
+
await wait_for(lambda: runtime.inspect(eid)["result"])
|
|
226
|
+
events = [e["event"] for e in runtime.store.events(sid)]
|
|
227
|
+
kinds = [
|
|
228
|
+
e["type"]
|
|
229
|
+
for e in events
|
|
230
|
+
if e["type"] in {"turn.started", "work.settled", "worker.exited"}
|
|
231
|
+
]
|
|
232
|
+
# The worker saw the peer turn start and waited for its result before its idle exit.
|
|
233
|
+
assert kinds == [
|
|
234
|
+
"turn.started",
|
|
235
|
+
"work.settled",
|
|
236
|
+
"turn.started",
|
|
237
|
+
"work.settled",
|
|
238
|
+
"worker.exited",
|
|
239
|
+
]
|
|
240
|
+
assert events[-1]["reason"] == "idle_timeout"
|
|
241
|
+
settled = [e for e in events if e["type"] == "work.settled"]
|
|
242
|
+
assert [e["message_ids"] for e in settled] == [["m1"], []]
|
|
243
|
+
assert settled[1]["message"] == "peer turn done"
|
|
244
|
+
finally:
|
|
245
|
+
await runtime.stop(eid)
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
version = 1
|
|
2
|
-
revision =
|
|
2
|
+
revision = 5
|
|
3
3
|
requires-python = ">=3.11"
|
|
4
4
|
resolution-markers = [
|
|
5
5
|
"python_full_version >= '3.14'",
|
|
6
6
|
"python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'",
|
|
7
|
-
"
|
|
7
|
+
"python_full_version < '3.12' or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten')",
|
|
8
8
|
]
|
|
9
9
|
|
|
10
10
|
[[package]]
|
|
11
11
|
name = "agent-runtime-py"
|
|
12
|
-
version = "0.2.
|
|
12
|
+
version = "0.2.1"
|
|
13
13
|
source = { editable = "." }
|
|
14
14
|
dependencies = [
|
|
15
15
|
{ name = "filelock" },
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## 0.2.0
|
|
4
|
-
|
|
5
|
-
- Breaking: requires typed-agent-hooks 0.3. Run the hooks with `agent_runtime.hooks.app.main()` and the host name (`codex` or `claude`) as the only argument; the host hook file sets the timeout. The `agent-runtime-hooks` console script is removed.
|
|
6
|
-
- Breaking: the Claude Agent SDK moves to the `claude` extra. `ClaudeRuntime` needs `agent-runtime-py[claude]`.
|
|
7
|
-
- Fix: a monitor worker whose command exceeded the output limit never exited and ignored SIGTERM.
|
|
8
|
-
- Fix: a Claude SessionStart, which carries no permission mode, kept the mode empty; it now keeps the mode that the same live Claude process reported.
|
|
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
|