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.
Files changed (23) hide show
  1. agent_runtime_py-0.2.1/CHANGELOG.md +14 -0
  2. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/PKG-INFO +1 -1
  3. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/pyproject.toml +1 -1
  4. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/src/agent_runtime/claude.py +3 -0
  5. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/src/agent_runtime/worker.py +22 -1
  6. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/tests/test_claude_runtime.py +81 -2
  7. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/uv.lock +3 -3
  8. agent_runtime_py-0.2.0/CHANGELOG.md +0 -8
  9. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/.github/workflows/ci.yml +0 -0
  10. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/.github/workflows/release.yml +0 -0
  11. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/.gitignore +0 -0
  12. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/LICENSE +0 -0
  13. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/README.md +0 -0
  14. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/src/agent_runtime/__init__.py +0 -0
  15. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/src/agent_runtime/bridge.py +0 -0
  16. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/src/agent_runtime/hooks.py +0 -0
  17. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/src/agent_runtime/monitor_worker.py +0 -0
  18. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/src/agent_runtime/monitors.py +0 -0
  19. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/src/agent_runtime/operations.py +0 -0
  20. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/src/agent_runtime/sessions.py +0 -0
  21. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/src/agent_runtime/store.py +0 -0
  22. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.1}/tests/test_hooks.py +0 -0
  23. {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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agent-runtime-py
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Reconnectable native agent workers with durable input receipts and events
5
5
  Project-URL: Repository, https://github.com/nimashoghi/agent-runtime
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "agent-runtime-py"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Reconnectable native agent workers with durable input receipts and events"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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
- active = self.active if human else None
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
- def result(text):
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":{"kind":"human"},"usage":{}})
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 = 3
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
- "(python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version < '3.12' and sys_platform == 'emscripten')",
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.0"
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.