agent-runtime-py 0.1.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.
- agent_runtime/__init__.py +5 -0
- agent_runtime/bridge.py +78 -0
- agent_runtime/claude.py +254 -0
- agent_runtime/hooks.py +82 -0
- agent_runtime/monitor_worker.py +145 -0
- agent_runtime/monitors.py +219 -0
- agent_runtime/operations.py +129 -0
- agent_runtime/sessions.py +201 -0
- agent_runtime/store.py +277 -0
- agent_runtime/worker.py +290 -0
- agent_runtime_py-0.1.0.dist-info/METADATA +66 -0
- agent_runtime_py-0.1.0.dist-info/RECORD +15 -0
- agent_runtime_py-0.1.0.dist-info/WHEEL +4 -0
- agent_runtime_py-0.1.0.dist-info/entry_points.txt +2 -0
- agent_runtime_py-0.1.0.dist-info/licenses/LICENSE +21 -0
agent_runtime/store.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Private durable execution state, input receipts, and ordered event cursors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sqlite3
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Store:
|
|
17
|
+
def __init__(self, root: Path) -> None:
|
|
18
|
+
self.root = root.expanduser().resolve()
|
|
19
|
+
self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
20
|
+
os.chmod(self.root, 0o700)
|
|
21
|
+
self.path = self.root / "runtime.sqlite3"
|
|
22
|
+
with self.connect() as db:
|
|
23
|
+
db.executescript(
|
|
24
|
+
"""
|
|
25
|
+
CREATE TABLE IF NOT EXISTS executions (
|
|
26
|
+
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, created REAL NOT NULL,
|
|
27
|
+
value TEXT NOT NULL
|
|
28
|
+
);
|
|
29
|
+
CREATE INDEX IF NOT EXISTS execution_sessions ON executions(session_id,created);
|
|
30
|
+
CREATE TABLE IF NOT EXISTS inputs (
|
|
31
|
+
session_id TEXT NOT NULL, id TEXT NOT NULL, execution_id TEXT NOT NULL,
|
|
32
|
+
digest TEXT NOT NULL, text TEXT NOT NULL, status TEXT NOT NULL,
|
|
33
|
+
created REAL NOT NULL, error TEXT, native_uuid TEXT,
|
|
34
|
+
PRIMARY KEY(session_id,id)
|
|
35
|
+
);
|
|
36
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
37
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT, execution_id TEXT NOT NULL,
|
|
38
|
+
session_id TEXT NOT NULL, value TEXT NOT NULL
|
|
39
|
+
);
|
|
40
|
+
CREATE INDEX IF NOT EXISTS event_sessions ON events(session_id,seq);
|
|
41
|
+
CREATE TABLE IF NOT EXISTS interactions (
|
|
42
|
+
id TEXT PRIMARY KEY, execution_id TEXT NOT NULL,
|
|
43
|
+
request TEXT NOT NULL, answer TEXT
|
|
44
|
+
);
|
|
45
|
+
CREATE TABLE IF NOT EXISTS controls (
|
|
46
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, execution_id TEXT NOT NULL,
|
|
47
|
+
operation TEXT NOT NULL, payload TEXT NOT NULL, state TEXT NOT NULL
|
|
48
|
+
);
|
|
49
|
+
"""
|
|
50
|
+
)
|
|
51
|
+
os.chmod(self.path, 0o600)
|
|
52
|
+
|
|
53
|
+
@contextmanager
|
|
54
|
+
def connect(self) -> Iterator[sqlite3.Connection]:
|
|
55
|
+
db = sqlite3.connect(self.path, timeout=30)
|
|
56
|
+
db.row_factory = sqlite3.Row
|
|
57
|
+
try:
|
|
58
|
+
with db:
|
|
59
|
+
yield db
|
|
60
|
+
finally:
|
|
61
|
+
db.close()
|
|
62
|
+
|
|
63
|
+
def create_execution(self, value: dict[str, Any]) -> bool:
|
|
64
|
+
with self.connect() as db:
|
|
65
|
+
cursor = db.execute(
|
|
66
|
+
"INSERT OR IGNORE INTO executions VALUES(?,?,?,?)",
|
|
67
|
+
(
|
|
68
|
+
value["execution_id"],
|
|
69
|
+
value["session_id"],
|
|
70
|
+
value["created_at"],
|
|
71
|
+
json.dumps(value),
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
return cursor.rowcount == 1
|
|
75
|
+
|
|
76
|
+
def execution(self, identity: str) -> dict[str, Any]:
|
|
77
|
+
with self.connect() as db:
|
|
78
|
+
row = db.execute("SELECT value FROM executions WHERE id=?", (identity,)).fetchone()
|
|
79
|
+
if row is None:
|
|
80
|
+
raise KeyError(f"unknown execution: {identity}")
|
|
81
|
+
return json.loads(row[0])
|
|
82
|
+
|
|
83
|
+
def latest(self, session_id: str) -> dict[str, Any] | None:
|
|
84
|
+
with self.connect() as db:
|
|
85
|
+
row = db.execute(
|
|
86
|
+
"SELECT value FROM executions WHERE session_id=? ORDER BY created DESC LIMIT 1",
|
|
87
|
+
(session_id,),
|
|
88
|
+
).fetchone()
|
|
89
|
+
return json.loads(row[0]) if row else None
|
|
90
|
+
|
|
91
|
+
def update(self, identity: str, **fields: Any) -> dict[str, Any]:
|
|
92
|
+
with self.connect() as db:
|
|
93
|
+
db.execute("BEGIN IMMEDIATE")
|
|
94
|
+
row = db.execute("SELECT value FROM executions WHERE id=?", (identity,)).fetchone()
|
|
95
|
+
if row is None:
|
|
96
|
+
raise KeyError(identity)
|
|
97
|
+
value = {**json.loads(row[0]), **fields}
|
|
98
|
+
db.execute("UPDATE executions SET value=? WHERE id=?", (json.dumps(value), identity))
|
|
99
|
+
return value
|
|
100
|
+
|
|
101
|
+
def accept(
|
|
102
|
+
self, session_id: str, execution_id: str, identity: str, text: str
|
|
103
|
+
) -> dict[str, Any]:
|
|
104
|
+
if not identity or not text.strip():
|
|
105
|
+
raise ValueError("message ID and text must be nonempty")
|
|
106
|
+
digest = hashlib.sha256(text.encode()).hexdigest()
|
|
107
|
+
with self.connect() as db:
|
|
108
|
+
db.execute("BEGIN IMMEDIATE")
|
|
109
|
+
row = db.execute(
|
|
110
|
+
"SELECT * FROM inputs WHERE session_id=? AND id=?", (session_id, identity)
|
|
111
|
+
).fetchone()
|
|
112
|
+
if row is not None:
|
|
113
|
+
if row["digest"] != digest:
|
|
114
|
+
raise ValueError("message ID was already used with different text")
|
|
115
|
+
else:
|
|
116
|
+
db.execute(
|
|
117
|
+
"INSERT INTO inputs(session_id,id,execution_id,digest,text,status,created) "
|
|
118
|
+
"VALUES(?,?,?,?,?,'accepted',?)",
|
|
119
|
+
(session_id, identity, execution_id, digest, text, time.time()),
|
|
120
|
+
)
|
|
121
|
+
receipt = self.receipt(session_id, identity)
|
|
122
|
+
assert receipt is not None
|
|
123
|
+
return receipt
|
|
124
|
+
|
|
125
|
+
def receipt(self, session_id: str, identity: str) -> dict[str, Any] | None:
|
|
126
|
+
with self.connect() as db:
|
|
127
|
+
row = db.execute(
|
|
128
|
+
"SELECT id AS message_id,execution_id,status,error,native_uuid,created "
|
|
129
|
+
"FROM inputs WHERE session_id=? AND id=?",
|
|
130
|
+
(session_id, identity),
|
|
131
|
+
).fetchone()
|
|
132
|
+
return dict(row) if row else None
|
|
133
|
+
|
|
134
|
+
def take_input(self, execution_id: str) -> dict[str, Any] | None:
|
|
135
|
+
with self.connect() as db:
|
|
136
|
+
db.execute("BEGIN IMMEDIATE")
|
|
137
|
+
row = db.execute(
|
|
138
|
+
"SELECT * FROM inputs WHERE execution_id=? AND status='accepted' "
|
|
139
|
+
"ORDER BY created LIMIT 1",
|
|
140
|
+
(execution_id,),
|
|
141
|
+
).fetchone()
|
|
142
|
+
if row is not None:
|
|
143
|
+
db.execute(
|
|
144
|
+
"UPDATE inputs SET status='dispatching' WHERE session_id=? AND id=?",
|
|
145
|
+
(row["session_id"], row["id"]),
|
|
146
|
+
)
|
|
147
|
+
return dict(row) if row else None
|
|
148
|
+
|
|
149
|
+
def input_status(
|
|
150
|
+
self,
|
|
151
|
+
session_id: str,
|
|
152
|
+
identity: str,
|
|
153
|
+
status: str,
|
|
154
|
+
*,
|
|
155
|
+
error: str | None = None,
|
|
156
|
+
native_uuid: str | None = None,
|
|
157
|
+
) -> None:
|
|
158
|
+
with self.connect() as db:
|
|
159
|
+
db.execute(
|
|
160
|
+
"UPDATE inputs SET status=?,error=?,native_uuid=COALESCE(?,native_uuid) "
|
|
161
|
+
"WHERE session_id=? AND id=?",
|
|
162
|
+
(status, error, native_uuid, session_id, identity),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def lose_dispatches(self, execution_id: str) -> None:
|
|
166
|
+
with self.connect() as db:
|
|
167
|
+
db.execute(
|
|
168
|
+
"UPDATE inputs SET status='uncertain',"
|
|
169
|
+
"error='worker exited before native admission acknowledgement' "
|
|
170
|
+
"WHERE execution_id=? AND status='dispatching'",
|
|
171
|
+
(execution_id,),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def reject_pending(self, execution_id: str, error: str) -> None:
|
|
175
|
+
with self.connect() as db:
|
|
176
|
+
db.execute(
|
|
177
|
+
"UPDATE inputs SET status='rejected',error=? "
|
|
178
|
+
"WHERE execution_id=? AND status='accepted'",
|
|
179
|
+
(error, execution_id),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def admit(self, execution_id: str, native_uuid: str) -> str | None:
|
|
183
|
+
with self.connect() as db:
|
|
184
|
+
db.execute("BEGIN IMMEDIATE")
|
|
185
|
+
row = db.execute(
|
|
186
|
+
"SELECT id FROM inputs "
|
|
187
|
+
"WHERE execution_id=? AND native_uuid=? AND status='dispatching'",
|
|
188
|
+
(execution_id, native_uuid),
|
|
189
|
+
).fetchone()
|
|
190
|
+
if row is not None:
|
|
191
|
+
db.execute(
|
|
192
|
+
"UPDATE inputs SET status='delivered',error=NULL "
|
|
193
|
+
"WHERE execution_id=? AND native_uuid=?",
|
|
194
|
+
(execution_id, native_uuid),
|
|
195
|
+
)
|
|
196
|
+
return row[0] if row else None
|
|
197
|
+
|
|
198
|
+
def append(self, execution_id: str, session_id: str, value: dict[str, Any]) -> int:
|
|
199
|
+
with self.connect() as db:
|
|
200
|
+
cursor = db.execute(
|
|
201
|
+
"INSERT INTO events(execution_id,session_id,value) VALUES(?,?,?)",
|
|
202
|
+
(execution_id, session_id, json.dumps(value)),
|
|
203
|
+
)
|
|
204
|
+
assert cursor.lastrowid is not None
|
|
205
|
+
return cursor.lastrowid
|
|
206
|
+
|
|
207
|
+
def events(self, session_id: str, after: int = 0) -> list[dict[str, Any]]:
|
|
208
|
+
with self.connect() as db:
|
|
209
|
+
rows = db.execute(
|
|
210
|
+
"SELECT * FROM events WHERE session_id=? AND seq>? ORDER BY seq",
|
|
211
|
+
(session_id, after),
|
|
212
|
+
).fetchall()
|
|
213
|
+
return [
|
|
214
|
+
{
|
|
215
|
+
"execution_id": row["execution_id"],
|
|
216
|
+
"cursor": {"execution_id": row["execution_id"], "offset": row["seq"]},
|
|
217
|
+
"event": json.loads(row["value"]),
|
|
218
|
+
}
|
|
219
|
+
for row in rows
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
def request(self, execution_id: str, identity: str, value: dict[str, Any]) -> None:
|
|
223
|
+
with self.connect() as db:
|
|
224
|
+
db.execute(
|
|
225
|
+
"INSERT OR IGNORE INTO interactions VALUES(?,?,?,NULL)",
|
|
226
|
+
(identity, execution_id, json.dumps(value)),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def interactions(self, execution_id: str) -> list[dict[str, Any]]:
|
|
230
|
+
with self.connect() as db:
|
|
231
|
+
rows = db.execute(
|
|
232
|
+
"SELECT request FROM interactions WHERE execution_id=? AND answer IS NULL",
|
|
233
|
+
(execution_id,),
|
|
234
|
+
).fetchall()
|
|
235
|
+
return [json.loads(row[0]) for row in rows]
|
|
236
|
+
|
|
237
|
+
def answer(self, execution_id: str, identity: str, value: dict[str, Any]) -> None:
|
|
238
|
+
encoded = json.dumps(value, sort_keys=True)
|
|
239
|
+
with self.connect() as db:
|
|
240
|
+
db.execute("BEGIN IMMEDIATE")
|
|
241
|
+
row = db.execute(
|
|
242
|
+
"SELECT answer FROM interactions WHERE id=? AND execution_id=?",
|
|
243
|
+
(identity, execution_id),
|
|
244
|
+
).fetchone()
|
|
245
|
+
if row is None:
|
|
246
|
+
raise KeyError("unknown interaction")
|
|
247
|
+
if row[0] is not None and row[0] != encoded:
|
|
248
|
+
raise ValueError("interaction already has a different answer")
|
|
249
|
+
db.execute("UPDATE interactions SET answer=? WHERE id=?", (encoded, identity))
|
|
250
|
+
|
|
251
|
+
def response(self, identity: str) -> dict[str, Any] | None:
|
|
252
|
+
with self.connect() as db:
|
|
253
|
+
row = db.execute("SELECT answer FROM interactions WHERE id=?", (identity,)).fetchone()
|
|
254
|
+
return json.loads(row[0]) if row and row[0] else None
|
|
255
|
+
|
|
256
|
+
def control(self, execution_id: str, operation: str, payload: dict[str, Any]) -> int:
|
|
257
|
+
with self.connect() as db:
|
|
258
|
+
cursor = db.execute(
|
|
259
|
+
"INSERT INTO controls(execution_id,operation,payload,state) "
|
|
260
|
+
"VALUES(?,?,?,'pending')",
|
|
261
|
+
(execution_id, operation, json.dumps(payload)),
|
|
262
|
+
)
|
|
263
|
+
assert cursor.lastrowid is not None
|
|
264
|
+
return cursor.lastrowid
|
|
265
|
+
|
|
266
|
+
def take_controls(self, execution_id: str) -> list[dict[str, Any]]:
|
|
267
|
+
with self.connect() as db:
|
|
268
|
+
db.execute("BEGIN IMMEDIATE")
|
|
269
|
+
rows = db.execute(
|
|
270
|
+
"SELECT * FROM controls WHERE execution_id=? AND state='pending' ORDER BY id",
|
|
271
|
+
(execution_id,),
|
|
272
|
+
).fetchall()
|
|
273
|
+
db.execute(
|
|
274
|
+
"UPDATE controls SET state='dispatched' WHERE execution_id=? AND state='pending'",
|
|
275
|
+
(execution_id,),
|
|
276
|
+
)
|
|
277
|
+
return [{**dict(row), "payload": json.loads(row["payload"])} for row in rows]
|
agent_runtime/worker.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""One detached owner for one native Claude process and its durable event stream."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import signal
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from dataclasses import asdict, is_dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import psutil
|
|
17
|
+
from claude_agent_sdk import (
|
|
18
|
+
TERMINAL_TASK_STATUSES,
|
|
19
|
+
AssistantMessage,
|
|
20
|
+
ClaudeAgentOptions,
|
|
21
|
+
ClaudeSDKClient,
|
|
22
|
+
PermissionResultAllow,
|
|
23
|
+
PermissionResultDeny,
|
|
24
|
+
ResultMessage,
|
|
25
|
+
TextBlock,
|
|
26
|
+
ToolPermissionContext,
|
|
27
|
+
UserMessage,
|
|
28
|
+
)
|
|
29
|
+
from filelock import FileLock
|
|
30
|
+
|
|
31
|
+
from agent_runtime.monitors import Monitors
|
|
32
|
+
from agent_runtime.store import Store
|
|
33
|
+
|
|
34
|
+
log = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Worker:
|
|
38
|
+
def __init__(self, store: Store, execution_id: str) -> None:
|
|
39
|
+
self.store = store
|
|
40
|
+
self.execution_id = execution_id
|
|
41
|
+
self.execution = store.execution(execution_id)
|
|
42
|
+
self.session_id = self.execution["session_id"]
|
|
43
|
+
self.stopping = asyncio.Event()
|
|
44
|
+
self.active: dict[str, Any] | None = None
|
|
45
|
+
self.last_activity = time.monotonic()
|
|
46
|
+
self.background_tasks: set[str] = set()
|
|
47
|
+
self.presentation_started = False
|
|
48
|
+
|
|
49
|
+
def emit(self, kind: str, **fields: Any) -> None:
|
|
50
|
+
self.store.append(self.execution_id, self.session_id, {"type": kind, **fields})
|
|
51
|
+
|
|
52
|
+
async def permission(
|
|
53
|
+
self, name: str, value: dict[str, Any], context: ToolPermissionContext
|
|
54
|
+
) -> Any:
|
|
55
|
+
identity = "Q" + uuid.uuid4().hex[:12]
|
|
56
|
+
self.store.request(
|
|
57
|
+
self.execution_id,
|
|
58
|
+
identity,
|
|
59
|
+
{
|
|
60
|
+
"id": identity,
|
|
61
|
+
"provider": "claude_code",
|
|
62
|
+
"method": "agent/question"
|
|
63
|
+
if name == "AskUserQuestion"
|
|
64
|
+
else "agent/requestApproval",
|
|
65
|
+
"params": {
|
|
66
|
+
"tool": name,
|
|
67
|
+
"input": value,
|
|
68
|
+
"title": context.title,
|
|
69
|
+
"description": context.description,
|
|
70
|
+
"reason": context.decision_reason,
|
|
71
|
+
"tool_use_id": context.tool_use_id,
|
|
72
|
+
"agent_id": context.agent_id,
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
self.emit("interaction.requested", id=identity)
|
|
77
|
+
while not self.stopping.is_set():
|
|
78
|
+
answer = self.store.response(identity)
|
|
79
|
+
if answer is not None:
|
|
80
|
+
if name == "AskUserQuestion":
|
|
81
|
+
answers = answer.get("answers")
|
|
82
|
+
if not isinstance(answers, dict) or not all(
|
|
83
|
+
isinstance(k, str) and isinstance(v, str) for k, v in answers.items()
|
|
84
|
+
):
|
|
85
|
+
return PermissionResultDeny(
|
|
86
|
+
message="invalid question response: expected a string-to-string mapping"
|
|
87
|
+
)
|
|
88
|
+
return PermissionResultAllow(updated_input={**value, "answers": answers})
|
|
89
|
+
if answer.get("decision") == "allow":
|
|
90
|
+
return PermissionResultAllow(updated_input=value)
|
|
91
|
+
return PermissionResultDeny(message="The user denied this operation.")
|
|
92
|
+
await asyncio.sleep(0.1)
|
|
93
|
+
return PermissionResultDeny(message="Worker is stopping.", interrupt=True)
|
|
94
|
+
|
|
95
|
+
async def receive(self, client: ClaudeSDKClient) -> None:
|
|
96
|
+
async for message in client.receive_messages():
|
|
97
|
+
self.last_activity = time.monotonic()
|
|
98
|
+
raw = asdict(message) if is_dataclass(message) else {"description": repr(message)}
|
|
99
|
+
self.emit("claude.message", message_class=type(message).__name__, message=raw)
|
|
100
|
+
if isinstance(message, UserMessage) and message.uuid:
|
|
101
|
+
if admitted := self.store.admit(self.execution_id, message.uuid):
|
|
102
|
+
self.emit("input.delivered", message_id=admitted, native_uuid=message.uuid)
|
|
103
|
+
elif isinstance(message, AssistantMessage) and message.parent_tool_use_id is None:
|
|
104
|
+
if not self.presentation_started:
|
|
105
|
+
self.emit("turn.started", origin="native")
|
|
106
|
+
self.presentation_started = True
|
|
107
|
+
text = "\n".join(
|
|
108
|
+
block.text for block in message.content if isinstance(block, TextBlock)
|
|
109
|
+
)
|
|
110
|
+
if text:
|
|
111
|
+
self.emit("agent.message", text=text, native_uuid=message.uuid)
|
|
112
|
+
elif isinstance(message, ResultMessage):
|
|
113
|
+
if message.session_id != self.session_id:
|
|
114
|
+
raise RuntimeError("native Claude returned a different session ID")
|
|
115
|
+
human = message.origin is None or message.origin.get("kind") == "human"
|
|
116
|
+
active = self.active if human else None
|
|
117
|
+
status = "failed" if message.is_error else "completed"
|
|
118
|
+
if message.terminal_reason in {"aborted_streaming", "aborted_tools"}:
|
|
119
|
+
status = "interrupted"
|
|
120
|
+
self.emit(
|
|
121
|
+
"agent.turn.completed",
|
|
122
|
+
status=status,
|
|
123
|
+
origin=message.origin,
|
|
124
|
+
terminal_reason=message.terminal_reason,
|
|
125
|
+
usage=message.usage,
|
|
126
|
+
)
|
|
127
|
+
self.emit(
|
|
128
|
+
"work.settled",
|
|
129
|
+
status=status,
|
|
130
|
+
message=message.result or "",
|
|
131
|
+
message_ids=[active["id"]] if active else [],
|
|
132
|
+
origin=message.origin,
|
|
133
|
+
errors=message.errors,
|
|
134
|
+
error="; ".join(message.errors or []) or None,
|
|
135
|
+
last_turn_status=status,
|
|
136
|
+
)
|
|
137
|
+
self.presentation_started = False
|
|
138
|
+
if active:
|
|
139
|
+
receipt = self.store.receipt(self.session_id, active["id"])
|
|
140
|
+
if receipt and receipt["status"] == "dispatching":
|
|
141
|
+
# A result does not identify which input UUID it accepted.
|
|
142
|
+
# Preserve uncertainty if the expected native echo was lost.
|
|
143
|
+
self.store.input_status(
|
|
144
|
+
self.session_id,
|
|
145
|
+
active["id"],
|
|
146
|
+
"uncertain",
|
|
147
|
+
error="native result arrived without input UUID acknowledgement",
|
|
148
|
+
)
|
|
149
|
+
self.active = None
|
|
150
|
+
else:
|
|
151
|
+
data = getattr(message, "data", {})
|
|
152
|
+
subtype = getattr(message, "subtype", "")
|
|
153
|
+
task_id = data.get("task_id") if isinstance(data, dict) else None
|
|
154
|
+
if task_id and subtype == "task_started":
|
|
155
|
+
self.background_tasks.add(task_id)
|
|
156
|
+
elif (task_id and subtype == "task_notification") or (
|
|
157
|
+
task_id
|
|
158
|
+
and subtype == "task_updated"
|
|
159
|
+
and data.get("patch", {}).get("status") in TERMINAL_TASK_STATUSES
|
|
160
|
+
):
|
|
161
|
+
self.background_tasks.discard(task_id)
|
|
162
|
+
if not self.stopping.is_set():
|
|
163
|
+
raise RuntimeError("native Claude event stream closed")
|
|
164
|
+
|
|
165
|
+
async def run(self) -> None:
|
|
166
|
+
policy = self.execution["configuration"]
|
|
167
|
+
options = ClaudeAgentOptions(
|
|
168
|
+
cwd=policy["cwd"],
|
|
169
|
+
cli_path=policy["claude"],
|
|
170
|
+
permission_mode=policy["permission_mode"],
|
|
171
|
+
allowed_tools=policy["allowed_tools"],
|
|
172
|
+
model=policy["model"],
|
|
173
|
+
setting_sources=["user", "project", "local"],
|
|
174
|
+
system_prompt={
|
|
175
|
+
"type": "preset",
|
|
176
|
+
"preset": "claude_code",
|
|
177
|
+
"append": policy["instructions"] or "",
|
|
178
|
+
},
|
|
179
|
+
can_use_tool=self.permission,
|
|
180
|
+
stderr=lambda line: log.debug("claude: %s", line),
|
|
181
|
+
extra_args={"replay-user-messages": None},
|
|
182
|
+
session_id=self.session_id
|
|
183
|
+
if not self.execution["resume"] or self.execution["fork"]
|
|
184
|
+
else None,
|
|
185
|
+
resume=self.execution["resume"],
|
|
186
|
+
fork_session=self.execution["fork"],
|
|
187
|
+
)
|
|
188
|
+
for signum in (signal.SIGTERM, signal.SIGINT):
|
|
189
|
+
asyncio.get_running_loop().add_signal_handler(signum, self.stopping.set)
|
|
190
|
+
client = ClaudeSDKClient(options)
|
|
191
|
+
reader: asyncio.Task[None] | None = None
|
|
192
|
+
result: dict[str, Any] = {"status": "stopped"}
|
|
193
|
+
try:
|
|
194
|
+
await client.connect()
|
|
195
|
+
self.store.update(self.execution_id, state="running", ready_at=time.time())
|
|
196
|
+
self.emit("worker.ready", provider="claude_code", session_id=self.session_id)
|
|
197
|
+
reader = asyncio.create_task(self.receive(client))
|
|
198
|
+
while not self.stopping.is_set():
|
|
199
|
+
if reader.done():
|
|
200
|
+
await reader
|
|
201
|
+
break
|
|
202
|
+
for control in self.store.take_controls(self.execution_id):
|
|
203
|
+
match control["operation"]:
|
|
204
|
+
case "stop":
|
|
205
|
+
self.stopping.set()
|
|
206
|
+
case "interrupt":
|
|
207
|
+
await client.interrupt()
|
|
208
|
+
case "set_model":
|
|
209
|
+
await client.set_model(control["payload"]["model"])
|
|
210
|
+
case _:
|
|
211
|
+
self.emit(
|
|
212
|
+
"control.failed", id=control["id"], error="unsupported operation"
|
|
213
|
+
)
|
|
214
|
+
if self.stopping.is_set():
|
|
215
|
+
break
|
|
216
|
+
if self.active is None:
|
|
217
|
+
self.active = self.store.take_input(self.execution_id)
|
|
218
|
+
if self.active:
|
|
219
|
+
native_uuid = str(uuid.uuid4())
|
|
220
|
+
self.store.input_status(
|
|
221
|
+
self.session_id,
|
|
222
|
+
self.active["id"],
|
|
223
|
+
"dispatching",
|
|
224
|
+
native_uuid=native_uuid,
|
|
225
|
+
)
|
|
226
|
+
text = self.active["text"]
|
|
227
|
+
input_id = self.active["id"]
|
|
228
|
+
|
|
229
|
+
async def prompt(native_uuid=native_uuid, text=text):
|
|
230
|
+
yield {
|
|
231
|
+
"type": "user",
|
|
232
|
+
"uuid": native_uuid,
|
|
233
|
+
"session_id": self.session_id,
|
|
234
|
+
"origin": {"kind": "human"},
|
|
235
|
+
"message": {"role": "user", "content": text},
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
self.emit("turn.started", message_id=input_id, native_uuid=native_uuid)
|
|
239
|
+
self.presentation_started = True
|
|
240
|
+
await client.query(prompt(), session_id=self.session_id)
|
|
241
|
+
self.last_activity = time.monotonic()
|
|
242
|
+
timeout = policy["idle_timeout_seconds"]
|
|
243
|
+
if (
|
|
244
|
+
timeout
|
|
245
|
+
and self.active is None
|
|
246
|
+
and not self.background_tasks
|
|
247
|
+
and time.monotonic() - self.last_activity >= timeout
|
|
248
|
+
and not any(
|
|
249
|
+
monitor["status"] in {"starting", "running"}
|
|
250
|
+
for monitor in Monitors().list(self.session_id)
|
|
251
|
+
)
|
|
252
|
+
):
|
|
253
|
+
result = {"status": "completed", "reason": "idle_timeout"}
|
|
254
|
+
break
|
|
255
|
+
await asyncio.sleep(0.1)
|
|
256
|
+
except Exception as error:
|
|
257
|
+
log.exception("Claude worker failed")
|
|
258
|
+
result = {"status": "failed", "error": str(error)}
|
|
259
|
+
finally:
|
|
260
|
+
self.stopping.set()
|
|
261
|
+
try:
|
|
262
|
+
await client.disconnect()
|
|
263
|
+
except Exception:
|
|
264
|
+
log.exception("Claude disconnect failed")
|
|
265
|
+
if reader is not None:
|
|
266
|
+
reader.cancel()
|
|
267
|
+
await asyncio.gather(reader, return_exceptions=True)
|
|
268
|
+
self.store.lose_dispatches(self.execution_id)
|
|
269
|
+
self.store.reject_pending(self.execution_id, "worker stopped before dispatch")
|
|
270
|
+
self.store.update(
|
|
271
|
+
self.execution_id, state="exited", result=result, exited_at=time.time()
|
|
272
|
+
)
|
|
273
|
+
self.emit("worker.exited", **result)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def main() -> None:
|
|
277
|
+
os.umask(0o077)
|
|
278
|
+
logging.basicConfig(
|
|
279
|
+
level=logging.DEBUG, format="%(asctime)s %(levelname)s %(name)s %(message)s"
|
|
280
|
+
)
|
|
281
|
+
store, identity = Store(Path(sys.argv[1])), sys.argv[2]
|
|
282
|
+
value = store.execution(identity)
|
|
283
|
+
with FileLock(str(store.root / f"lease-{value['session_id']}.lock"), timeout=0):
|
|
284
|
+
process = psutil.Process()
|
|
285
|
+
store.update(identity, pid=process.pid, process_created_at=process.create_time())
|
|
286
|
+
asyncio.run(Worker(store, identity).run())
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
if __name__ == "__main__":
|
|
290
|
+
main()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: agent-runtime-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Reconnectable native agent workers with durable input receipts and events
|
|
5
|
+
Project-URL: Repository, https://github.com/nimashoghi/agent-runtime
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: claude-agent-sdk==0.2.159
|
|
10
|
+
Requires-Dist: filelock<4,>=3.16
|
|
11
|
+
Requires-Dist: platformdirs<5,>=4
|
|
12
|
+
Requires-Dist: psutil<8,>=7
|
|
13
|
+
Requires-Dist: typed-agent-hooks<0.3,>=0.2.2
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# Agent runtime
|
|
17
|
+
|
|
18
|
+
`agent-runtime-py` provides detached Claude Code workers that applications can reconnect to after restarting. Import it as `agent_runtime`. It uses the official Claude Agent SDK and your installed Claude Code executable, loading native user and project settings, hooks, skills, and MCP servers.
|
|
19
|
+
|
|
20
|
+
This repository is under active development. The current implementation supports macOS and Linux and has been exercised with Claude Code 2.1.281 and Claude Agent SDK 0.2.159. Windows is not supported.
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from agent_runtime import ClaudeRuntime, LaunchOptions
|
|
25
|
+
|
|
26
|
+
runtime = ClaudeRuntime(Path.home() / ".local/state/example/claude")
|
|
27
|
+
execution = await runtime.start(
|
|
28
|
+
"Inspect this project's tests.",
|
|
29
|
+
message_id="request-123",
|
|
30
|
+
launch_id="conversation-456:request-123",
|
|
31
|
+
options=LaunchOptions(cwd=Path.cwd()),
|
|
32
|
+
)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The default permission mode is Claude's ordinary permission behavior. Pending approvals and questions are persisted in `runtime.store.interactions(execution_id)` and remain pending until the application supplies an explicit answer. Applications must present these requests to the user; this library does not auto-approve them.
|
|
36
|
+
|
|
37
|
+
## Lifetime and delivery
|
|
38
|
+
|
|
39
|
+
- Each execution is a detached Python worker owning one native Claude SDK connection. Destroying the application client does not stop the worker.
|
|
40
|
+
- A native session can have several executions after explicit stop and resume. A process lease prevents two managed workers from resuming it simultaneously.
|
|
41
|
+
- Message IDs are idempotent within a session. Reusing an ID with different text is an error. A launch ID also fixes the initial prompt and launch configuration.
|
|
42
|
+
- `accepted` means privately journaled, `dispatching` means sent or being sent, and `delivered` requires Claude to echo the exact input UUID. A crash before that acknowledgement produces `uncertain`; the runtime never silently replays that input.
|
|
43
|
+
- Process identity includes PID and process creation time. Restarting a dead worker requires an explicit resume request. A process restart does not imply resubmitting previously delivered work.
|
|
44
|
+
- Events have durable increasing cursors across executions. Native SDK messages are retained as `claude.message`; presentation events include `agent.message`, `agent.turn.completed`, and `work.settled`.
|
|
45
|
+
- A normal turn result leaves the worker available for more requests. The idle timeout applies only when there is no active request or tracked background task or running monitor. Set it to zero to keep the worker open until explicitly stopped.
|
|
46
|
+
- Private SQLite state and logs contain conversation content. Environment variables are passed to the child without being serialized into launch configuration.
|
|
47
|
+
|
|
48
|
+
`runtime.stop(execution_id)` requests shutdown. `runtime.attach(execution_id)` returns an interactive resume command only after the managed worker has stopped, preventing concurrent native ownership. It must be launched from the recorded working directory.
|
|
49
|
+
|
|
50
|
+
## Monitors and local peer messages
|
|
51
|
+
|
|
52
|
+
Install `agent-runtime-hooks` using a typed-agent-hooks `Collection` for both providers. The hooks register native process identities and current permission modes. Claude hooks start a private delivery child whose inherited native inbox token remains in memory; it is never written to the registry or used for messages to another session.
|
|
53
|
+
|
|
54
|
+
`agent_runtime.operations` supplies `monitor_start`, `monitor_list`, `monitor_get`, `monitor_stop`, `monitor_events`, `agents`, and `send`. Codex operations delegate to the installed `codexr` CLI. Claude monitors run a detached foreground Bash command and retain stdout observations in SQLite; stderr goes to a private log. A 1 MiB output limit stops an overly verbose monitor. Delivery batches retain each monitor's launch permission class. Native Claude receives these as peer context and applies its own admission policy, including holding messages if permission modes require approval. `transport-written` confirms only a socket write, not admission or model processing. Interrupted writes remain uncertain and are not replayed automatically.
|
|
55
|
+
|
|
56
|
+
A normal Claude monitor stops when its owning native process exits or its deadline expires. A persistent monitor has no deadline and continues collecting output after CLI exit; resuming the exact native session starts a new delivery child for pending observations. This does not keep a plain `claude -p` process open or restart processes after a host reboot. Interactive sessions and managed SDK workers receive observations between turns. A managed worker's idle timeout does not end a running monitor.
|
|
57
|
+
|
|
58
|
+
Cross-session sends identify their source and never use the destination's own-event token. Unknown source permission modes fail closed. Codex receipts preserve its native delivered/queued meanings; Claude socket receipts are transport-only. Native policy or a stopped process can prevent delivery.
|
|
59
|
+
|
|
60
|
+
Goals use Claude's native `/goal`, including its evaluator, retry limits and resume behavior. There is no second goal store or emulated Codex goal API. An objective can survive native resume while usage baselines reset; unrecoverable errors can clear it. See [Claude goals](https://code.claude.com/docs/en/goal).
|
|
61
|
+
|
|
62
|
+
## Development
|
|
63
|
+
|
|
64
|
+
Run `uv run pytest -q`. The integration tests use the real Claude Agent SDK and detached worker processes against a deterministic fake native CLI. They cover application reconnection, stop/resume, duplicate admission, lost acknowledgement, explicit permission decisions, actual shell monitor failure, process-tree shutdown, deadlines, retained observations across native-owner exit/resume, and peer permission labels. They require no model account or API credentials.
|
|
65
|
+
|
|
66
|
+
Release publishing uses GitHub Actions trusted publishing. Configure a pending PyPI publisher with project `agent-runtime-py`, owner `nimashoghi`, repository `agent-runtime`, workflow `release.yml`, and environment `pypi`. Publishing a GitHub release whose tag matches `v<package-version>` builds and publishes the package.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
agent_runtime/__init__.py,sha256=s8nyCGrLbrqQiLh_JDfD9hdyEv0TEZ_EPYS6TF8xIDo,177
|
|
2
|
+
agent_runtime/bridge.py,sha256=YBdzf6-qgbnAToxkY-qEPVXB-STQY7CUWlQn7jRyHoM,3224
|
|
3
|
+
agent_runtime/claude.py,sha256=0-NYA_fy6H2KLp-ZStbGGV3ZwK3bTLkQcnA64U_-t5E,9903
|
|
4
|
+
agent_runtime/hooks.py,sha256=rR6RbC6W31sYc04n0W16zurrpSG8FacyVpA_xd52w58,2461
|
|
5
|
+
agent_runtime/monitor_worker.py,sha256=CfTs6ultwMPneoNdhrwCBWUarAKl9je6Z4ahBcOg0Cg,5771
|
|
6
|
+
agent_runtime/monitors.py,sha256=shCIV8q5n0fay63FM6D5BGjczDDaz29ltYFHOXfn9Co,8752
|
|
7
|
+
agent_runtime/operations.py,sha256=FCWL3oj0aoByUF7NzU1lFf_-oyGkDN4pKEmOLI14qoI,4126
|
|
8
|
+
agent_runtime/sessions.py,sha256=bP82Lusb2XyerIx4Pk1N4dDJAbEER74rsWG-0HS-Zko,8131
|
|
9
|
+
agent_runtime/store.py,sha256=Ca0kWe4mx4zO-4-t1cBJhqtYLcV_dejhegp9_vVGN7w,11533
|
|
10
|
+
agent_runtime/worker.py,sha256=b0SJ4ewYuxkX5-QNn58jznGb_3OZIxoZ6u9_JAulUtQ,12816
|
|
11
|
+
agent_runtime_py-0.1.0.dist-info/METADATA,sha256=P_r8Gvk8mKWZSMBLayzZExyJFDPpRkzyY__8bt2DeJE,6553
|
|
12
|
+
agent_runtime_py-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
13
|
+
agent_runtime_py-0.1.0.dist-info/entry_points.txt,sha256=xM6Z54Wl_Ry4eQ835rdGJ0bz92lMXMJW8U1sJd-gkL4,65
|
|
14
|
+
agent_runtime_py-0.1.0.dist-info/licenses/LICENSE,sha256=pLYhRdUXNvcg7DNLI4Ce74hsHeGEOwDXIRjVyI0MxnI,1068
|
|
15
|
+
agent_runtime_py-0.1.0.dist-info/RECORD,,
|