subagent-cli 0.1.1__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.
@@ -0,0 +1,558 @@
1
+ """Turn operations over worker runtime state and event journal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import uuid
7
+ from typing import Any
8
+
9
+ from .approval_utils import resolve_option
10
+ from .config import SubagentConfig
11
+ from .errors import SubagentError
12
+ from .runtime_service import restart_worker_runtime, runtime_request
13
+ from .state import (
14
+ WORKER_STATE_IDLE,
15
+ WORKER_STATE_RUNNING,
16
+ WORKER_STATE_WAITING_APPROVAL,
17
+ StateStore,
18
+ )
19
+
20
+
21
+ def _normalize_event(event: dict[str, Any], *, include_raw: bool = False) -> dict[str, Any]:
22
+ payload: dict[str, Any] = {
23
+ "schemaVersion": "v1",
24
+ "eventId": event["event_id"],
25
+ "ts": event["ts"],
26
+ "workerId": event["worker_id"],
27
+ "type": event["event_type"],
28
+ "data": event["data"],
29
+ }
30
+ turn_id = event.get("turn_id")
31
+ if turn_id:
32
+ payload["turnId"] = turn_id
33
+ if include_raw and event.get("raw") is not None:
34
+ payload["raw"] = event["raw"]
35
+ return payload
36
+
37
+
38
+ def _ensure_worker_sendable(worker: dict[str, Any]) -> None:
39
+ state = str(worker["state"])
40
+ if state != WORKER_STATE_IDLE:
41
+ raise SubagentError(
42
+ code="WORKER_BUSY",
43
+ message="worker has an active turn",
44
+ retryable=False,
45
+ details={"state": state},
46
+ )
47
+
48
+
49
+ def _parse_until_set(until: str | None) -> set[str]:
50
+ if until is None:
51
+ return set()
52
+ trimmed = until.strip()
53
+ if not trimmed or trimmed in {"*", "any"}:
54
+ return set()
55
+ return {part.strip() for part in trimmed.split(",") if part.strip()}
56
+
57
+
58
+ def _begin_turn(
59
+ store: StateStore,
60
+ *,
61
+ worker_id: str,
62
+ text: str,
63
+ blocks: list[dict[str, Any]] | None,
64
+ runtime_kind: str,
65
+ ) -> tuple[str, dict[str, Any]]:
66
+ turn_id = f"turn_{uuid.uuid4().hex[:10]}"
67
+ store.update_worker_state(worker_id, next_state=WORKER_STATE_RUNNING)
68
+ store.set_worker_active_turn(worker_id, turn_id)
69
+
70
+ store.append_worker_event(
71
+ worker_id,
72
+ event_type="turn.started",
73
+ turn_id=turn_id,
74
+ data={
75
+ "turnId": turn_id,
76
+ "input": {
77
+ "text": text,
78
+ "blocksCount": len(blocks or []),
79
+ },
80
+ },
81
+ raw={"runtime": runtime_kind, "phase": "turn.started"},
82
+ )
83
+ message_event = store.append_worker_event(
84
+ worker_id,
85
+ event_type="message.sent",
86
+ turn_id=turn_id,
87
+ data={
88
+ "text": text,
89
+ "blocks": blocks or [],
90
+ },
91
+ raw={"runtime": runtime_kind, "phase": "message.sent"},
92
+ )
93
+ return turn_id, message_event
94
+
95
+
96
+ def _complete_turn(
97
+ store: StateStore,
98
+ *,
99
+ worker_id: str,
100
+ turn_id: str,
101
+ runtime_kind: str,
102
+ outcome: str = "completed",
103
+ details: dict[str, Any] | None = None,
104
+ ) -> dict[str, Any]:
105
+ store.update_worker_state(worker_id, next_state=WORKER_STATE_IDLE)
106
+ payload = {
107
+ "turnId": turn_id,
108
+ "outcome": outcome,
109
+ "state": WORKER_STATE_IDLE,
110
+ }
111
+ if details:
112
+ payload.update(details)
113
+ return store.append_worker_event(
114
+ worker_id,
115
+ event_type="turn.completed",
116
+ turn_id=turn_id,
117
+ data=payload,
118
+ raw={"runtime": runtime_kind, "phase": "turn.completed"},
119
+ )
120
+
121
+
122
+ def _simulate_send_message(
123
+ store: StateStore,
124
+ *,
125
+ worker_id: str,
126
+ text: str,
127
+ blocks: list[dict[str, Any]] | None,
128
+ request_approval: bool,
129
+ ) -> dict[str, Any]:
130
+ turn_id, message_event = _begin_turn(
131
+ store,
132
+ worker_id=worker_id,
133
+ text=text,
134
+ blocks=blocks,
135
+ runtime_kind="local",
136
+ )
137
+ if request_approval:
138
+ request = store.create_approval_request(
139
+ worker_id,
140
+ turn_id=turn_id,
141
+ message=f"Approval requested for turn {turn_id}",
142
+ )
143
+ store.update_worker_state(worker_id, next_state=WORKER_STATE_WAITING_APPROVAL)
144
+ approval_event = store.append_worker_event(
145
+ worker_id,
146
+ event_type="approval.requested",
147
+ turn_id=turn_id,
148
+ data={
149
+ "requestId": request["request_id"],
150
+ "kind": request["kind"],
151
+ "message": request["message"],
152
+ "options": request["options"],
153
+ },
154
+ raw={"runtime": "local", "phase": "approval.requested"},
155
+ )
156
+ return {
157
+ "workerId": worker_id,
158
+ "turnId": turn_id,
159
+ "state": WORKER_STATE_WAITING_APPROVAL,
160
+ "requestId": request["request_id"],
161
+ "eventId": approval_event["event_id"],
162
+ }
163
+
164
+ store.append_worker_event(
165
+ worker_id,
166
+ event_type="progress.message",
167
+ turn_id=turn_id,
168
+ data={
169
+ "role": "assistant",
170
+ "text": "STATUS: turn accepted and completed in local runtime.",
171
+ },
172
+ raw={"runtime": "local", "phase": "progress.message"},
173
+ )
174
+ completed_event = _complete_turn(
175
+ store,
176
+ worker_id=worker_id,
177
+ turn_id=turn_id,
178
+ runtime_kind="local",
179
+ )
180
+ return {
181
+ "workerId": worker_id,
182
+ "turnId": turn_id,
183
+ "state": WORKER_STATE_IDLE,
184
+ "eventId": completed_event["event_id"],
185
+ "acceptedEventId": message_event["event_id"],
186
+ }
187
+
188
+
189
+ def _send_via_runtime(
190
+ store: StateStore,
191
+ *,
192
+ worker_id: str,
193
+ text: str,
194
+ blocks: list[dict[str, Any]] | None,
195
+ config: SubagentConfig | None,
196
+ ) -> dict[str, Any]:
197
+ turn_id, message_event = _begin_turn(
198
+ store,
199
+ worker_id=worker_id,
200
+ text=text,
201
+ blocks=blocks,
202
+ runtime_kind="acp-stdio",
203
+ )
204
+ try:
205
+ runtime_result = _runtime_request_with_restart(
206
+ store,
207
+ config=config,
208
+ worker_id=worker_id,
209
+ method="start_turn",
210
+ params={
211
+ "turnId": turn_id,
212
+ "text": text,
213
+ "blocks": blocks or [],
214
+ },
215
+ timeout_seconds=3600.0,
216
+ )
217
+ except SubagentError as error:
218
+ store.append_worker_event(
219
+ worker_id,
220
+ event_type="turn.failed",
221
+ turn_id=turn_id,
222
+ data={"turnId": turn_id, "error": error.to_dict()},
223
+ raw={"runtime": "acp-stdio", "phase": "turn.failed"},
224
+ )
225
+ store.update_worker_state(
226
+ worker_id,
227
+ next_state=WORKER_STATE_IDLE,
228
+ last_error=error.message,
229
+ )
230
+ raise
231
+
232
+ state = runtime_result.get("state")
233
+ if state == WORKER_STATE_WAITING_APPROVAL:
234
+ request_id = runtime_result.get("requestId")
235
+ if not isinstance(request_id, str):
236
+ raise SubagentError(
237
+ code="BACKEND_PROTOCOL_ERROR",
238
+ message="Runtime response missing approval requestId.",
239
+ details={"response": runtime_result},
240
+ )
241
+ latest_event = store.get_latest_worker_event(worker_id)
242
+ return {
243
+ "workerId": worker_id,
244
+ "turnId": turn_id,
245
+ "state": WORKER_STATE_WAITING_APPROVAL,
246
+ "requestId": request_id,
247
+ "eventId": latest_event["event_id"] if latest_event else None,
248
+ "acceptedEventId": message_event["event_id"],
249
+ }
250
+ if state != WORKER_STATE_IDLE:
251
+ raise SubagentError(
252
+ code="BACKEND_PROTOCOL_ERROR",
253
+ message="Runtime returned an unknown state.",
254
+ details={"response": runtime_result},
255
+ )
256
+ event_id = runtime_result.get("eventId")
257
+ if not isinstance(event_id, str):
258
+ latest_event = store.get_latest_worker_event(worker_id)
259
+ event_id = str(latest_event["event_id"]) if latest_event is not None else ""
260
+ return {
261
+ "workerId": worker_id,
262
+ "turnId": turn_id,
263
+ "state": WORKER_STATE_IDLE,
264
+ "eventId": event_id,
265
+ "acceptedEventId": message_event["event_id"],
266
+ }
267
+
268
+
269
+ def _runtime_request_with_restart(
270
+ store: StateStore,
271
+ *,
272
+ config: SubagentConfig | None,
273
+ worker_id: str,
274
+ method: str,
275
+ params: dict[str, Any],
276
+ timeout_seconds: float,
277
+ ) -> dict[str, Any]:
278
+ try:
279
+ return runtime_request(
280
+ store,
281
+ worker_id=worker_id,
282
+ method=method,
283
+ params=params,
284
+ timeout_seconds=timeout_seconds,
285
+ )
286
+ except SubagentError as error:
287
+ if error.code != "BACKEND_UNAVAILABLE" or config is None:
288
+ raise
289
+ restart_worker_runtime(store, config, worker_id=worker_id, timeout_seconds=10.0)
290
+ return runtime_request(
291
+ store,
292
+ worker_id=worker_id,
293
+ method=method,
294
+ params=params,
295
+ timeout_seconds=timeout_seconds,
296
+ )
297
+
298
+
299
+ def send_message(
300
+ store: StateStore,
301
+ *,
302
+ worker_id: str,
303
+ text: str,
304
+ blocks: list[dict[str, Any]] | None = None,
305
+ request_approval: bool = False,
306
+ config: SubagentConfig | None = None,
307
+ execution_mode: str = "strict",
308
+ ) -> dict[str, Any]:
309
+ worker = store.get_worker(worker_id)
310
+ if worker is None:
311
+ raise SubagentError(
312
+ code="WORKER_NOT_FOUND",
313
+ message=f"Worker not found: {worker_id}",
314
+ details={"workerId": worker_id},
315
+ )
316
+ _ensure_worker_sendable(worker)
317
+ if execution_mode not in {"strict", "simulate"}:
318
+ raise SubagentError(
319
+ code="INVALID_ARGUMENT",
320
+ message=f"Unknown execution mode: {execution_mode}",
321
+ details={"executionMode": execution_mode},
322
+ )
323
+ if request_approval:
324
+ return _simulate_send_message(
325
+ store,
326
+ worker_id=worker_id,
327
+ text=text,
328
+ blocks=blocks,
329
+ request_approval=True,
330
+ )
331
+ if execution_mode == "simulate":
332
+ return _simulate_send_message(
333
+ store,
334
+ worker_id=worker_id,
335
+ text=text,
336
+ blocks=blocks,
337
+ request_approval=False,
338
+ )
339
+ return _send_via_runtime(
340
+ store,
341
+ worker_id=worker_id,
342
+ text=text,
343
+ blocks=blocks,
344
+ config=config,
345
+ )
346
+
347
+
348
+ def watch_events(
349
+ store: StateStore,
350
+ *,
351
+ worker_id: str,
352
+ from_event_id: str | None = None,
353
+ follow: bool = False,
354
+ timeout_seconds: float = 1.0,
355
+ include_raw: bool = False,
356
+ ) -> list[dict[str, Any]]:
357
+ collected: list[dict[str, Any]] = []
358
+ cursor = from_event_id
359
+ if not follow:
360
+ events = store.list_worker_events(worker_id, from_event_id=cursor)
361
+ return [_normalize_event(event, include_raw=include_raw) for event in events]
362
+
363
+ deadline = time.monotonic() + timeout_seconds
364
+ while True:
365
+ events = store.list_worker_events(worker_id, from_event_id=cursor)
366
+ if events:
367
+ cursor = str(events[-1]["event_id"])
368
+ collected.extend(_normalize_event(event, include_raw=include_raw) for event in events)
369
+ if time.monotonic() >= deadline:
370
+ break
371
+ time.sleep(0.05)
372
+ return collected
373
+
374
+
375
+ def wait_for_event(
376
+ store: StateStore,
377
+ *,
378
+ worker_id: str,
379
+ until: str,
380
+ from_event_id: str | None = None,
381
+ timeout_seconds: float = 10.0,
382
+ ) -> dict[str, Any]:
383
+ until_set = _parse_until_set(until)
384
+ deadline = time.monotonic() + timeout_seconds
385
+ cursor = from_event_id
386
+ while True:
387
+ events = store.list_worker_events(worker_id, from_event_id=cursor)
388
+ if events:
389
+ for event in events:
390
+ event_type = str(event["event_type"])
391
+ if not until_set or event_type in until_set:
392
+ return _normalize_event(event)
393
+ cursor = str(events[-1]["event_id"])
394
+ if time.monotonic() >= deadline:
395
+ break
396
+ time.sleep(0.05)
397
+ raise SubagentError(
398
+ code="WAIT_TIMEOUT",
399
+ message=f"No event matched `{until}` before timeout",
400
+ retryable=True,
401
+ details={
402
+ "workerId": worker_id,
403
+ "until": until,
404
+ "timeoutSeconds": timeout_seconds,
405
+ },
406
+ )
407
+
408
+
409
+ def cancel_turn(
410
+ store: StateStore,
411
+ *,
412
+ worker_id: str,
413
+ reason: str | None = None,
414
+ config: SubagentConfig | None = None,
415
+ ) -> dict[str, Any]:
416
+ worker = store.get_worker(worker_id)
417
+ if worker is None:
418
+ raise SubagentError(
419
+ code="WORKER_NOT_FOUND",
420
+ message=f"Worker not found: {worker_id}",
421
+ details={"workerId": worker_id},
422
+ )
423
+ state = str(worker["state"])
424
+ if state not in {WORKER_STATE_RUNNING, WORKER_STATE_WAITING_APPROVAL}:
425
+ raise SubagentError(
426
+ code="WORKER_NOT_RUNNING",
427
+ message="worker has no active turn to cancel",
428
+ details={"workerId": worker_id, "state": state},
429
+ )
430
+ runtime_socket = worker.get("runtime_socket")
431
+ if isinstance(runtime_socket, str) and runtime_socket:
432
+ return _runtime_request_with_restart(
433
+ store,
434
+ config=config,
435
+ worker_id=worker_id,
436
+ method="cancel_turn",
437
+ params={"reason": reason or "canceled by manager"},
438
+ timeout_seconds=120.0,
439
+ )
440
+ turn_id = worker.get("active_turn_id")
441
+ store.update_worker_state(worker_id, next_state="canceling")
442
+ canceled_event = store.append_worker_event(
443
+ worker_id,
444
+ event_type="turn.canceled",
445
+ turn_id=str(turn_id) if turn_id else None,
446
+ data={
447
+ "turnId": turn_id,
448
+ "reason": reason or "canceled by manager",
449
+ },
450
+ raw={"runtime": "local", "phase": "turn.canceled"},
451
+ )
452
+ store.update_worker_state(worker_id, next_state=WORKER_STATE_IDLE)
453
+ return {
454
+ "workerId": worker_id,
455
+ "state": WORKER_STATE_IDLE,
456
+ "eventId": canceled_event["event_id"],
457
+ "turnId": turn_id,
458
+ }
459
+
460
+
461
+ def approve_request(
462
+ store: StateStore,
463
+ *,
464
+ worker_id: str,
465
+ request_id: str,
466
+ decision: str | None = None,
467
+ option_id: str | None = None,
468
+ alias: str | None = None,
469
+ note: str | None = None,
470
+ config: SubagentConfig | None = None,
471
+ ) -> dict[str, Any]:
472
+ worker = store.get_worker(worker_id)
473
+ if worker is None:
474
+ raise SubagentError(
475
+ code="WORKER_NOT_FOUND",
476
+ message=f"Worker not found: {worker_id}",
477
+ details={"workerId": worker_id},
478
+ )
479
+ runtime_socket = worker.get("runtime_socket")
480
+ if isinstance(runtime_socket, str) and runtime_socket:
481
+ return _runtime_request_with_restart(
482
+ store,
483
+ config=config,
484
+ worker_id=worker_id,
485
+ method="approve",
486
+ params={
487
+ "requestId": request_id,
488
+ "decision": decision,
489
+ "optionId": option_id,
490
+ "alias": alias,
491
+ "note": note,
492
+ },
493
+ timeout_seconds=120.0,
494
+ )
495
+ if str(worker["state"]) != WORKER_STATE_WAITING_APPROVAL:
496
+ raise SubagentError(
497
+ code="WORKER_NOT_WAITING_APPROVAL",
498
+ message="worker is not waiting for approval",
499
+ details={"workerId": worker_id, "state": worker["state"]},
500
+ )
501
+ request = store.get_approval_request(worker_id, request_id)
502
+ if request is None:
503
+ raise SubagentError(
504
+ code="APPROVAL_NOT_FOUND",
505
+ message=f"Approval request not found: {request_id}",
506
+ details={"workerId": worker_id, "requestId": request_id},
507
+ )
508
+ selected_option_id, selected_alias, resolved_decision = resolve_option(
509
+ request,
510
+ decision=decision,
511
+ option_id=option_id,
512
+ alias=alias,
513
+ )
514
+ decided = store.decide_approval_request(
515
+ worker_id,
516
+ request_id,
517
+ decision=resolved_decision,
518
+ selected_option_id=selected_option_id,
519
+ selected_alias=selected_alias,
520
+ note=note,
521
+ )
522
+ turn_id = request.get("turn_id")
523
+ store.append_worker_event(
524
+ worker_id,
525
+ event_type="approval.decided",
526
+ turn_id=str(turn_id) if turn_id else None,
527
+ data={
528
+ "requestId": request_id,
529
+ "decision": resolved_decision,
530
+ "optionId": selected_option_id,
531
+ "alias": selected_alias,
532
+ "note": note,
533
+ },
534
+ raw={"runtime": "local", "phase": "approval.decided"},
535
+ )
536
+ store.update_worker_state(worker_id, next_state=WORKER_STATE_RUNNING)
537
+ outcome = "approved" if selected_option_id in {"allow", "approve", "yes"} else "rejected"
538
+ completed_event = store.append_worker_event(
539
+ worker_id,
540
+ event_type="turn.completed",
541
+ turn_id=str(turn_id) if turn_id else None,
542
+ data={
543
+ "turnId": turn_id,
544
+ "outcome": outcome,
545
+ "state": WORKER_STATE_IDLE,
546
+ },
547
+ raw={"runtime": "local", "phase": "turn.completed"},
548
+ )
549
+ store.update_worker_state(worker_id, next_state=WORKER_STATE_IDLE)
550
+ return {
551
+ "workerId": worker_id,
552
+ "requestId": request_id,
553
+ "decision": decided["decision"],
554
+ "optionId": decided["selected_option_id"],
555
+ "alias": decided["selected_alias"],
556
+ "state": WORKER_STATE_IDLE,
557
+ "eventId": completed_event["event_id"],
558
+ }