abstractassistant 0.3.5__py3-none-any.whl → 0.4.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.
@@ -0,0 +1,583 @@
1
+ """Agentic backend for AbstractAssistant (Runtime + AbstractAgent).
2
+
3
+ This module is UI-agnostic. UIs (Qt/tray, CLI) should drive it via:
4
+ - `AgentHost.run_turn(...)` (generator of structured events)
5
+
6
+ Key invariants:
7
+ - Durable state lives in AbstractRuntime stores (JSON-safe vars + ledger).
8
+ - Tool callables are held only by the host (MappingToolExecutor); runtime persists only specs/requests/results.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import uuid
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ import json
17
+ from typing import Any, Callable, Dict, Generator, Iterable, List, Optional, Sequence
18
+
19
+ from abstractagent.agents.codeact import CodeActAgent
20
+ from abstractagent.agents.memact import MemActAgent
21
+ from abstractagent.agents.react import ReactAgent
22
+ from abstractagent.tools import ALL_TOOLS
23
+ from abstractruntime import (
24
+ RunState,
25
+ RunStatus,
26
+ WaitReason,
27
+ WaitState,
28
+ FileArtifactStore,
29
+ JsonFileRunStore,
30
+ JsonlLedgerStore,
31
+ )
32
+ from abstractruntime.integrations.abstractcore import MappingToolExecutor, PassthroughToolExecutor, create_local_runtime
33
+
34
+ from .session_store import SessionSnapshot, SessionStore
35
+ from .tool_policy import ToolApprovalPolicy
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class AgentHostConfig:
40
+ provider: str
41
+ model: str
42
+ agent_kind: str = "react" # react|codeact|memact
43
+ data_dir: Path = Path.home() / ".abstractassistant"
44
+
45
+ # Workspace scoping (used by runtime effect handlers before tool execution).
46
+ workspace_root: Optional[str] = None
47
+ workspace_access_mode: str = "workspace_only"
48
+ workspace_ignored_paths: Optional[List[str]] = None
49
+ workspace_allowed_paths: Optional[List[str]] = None
50
+
51
+ # Agent behavior
52
+ max_iterations: int = 25
53
+ plan_mode: bool = False
54
+ review_mode: bool = True
55
+ review_max_rounds: int = 3
56
+
57
+ # Tool approvals
58
+ tool_policy: ToolApprovalPolicy = field(default_factory=ToolApprovalPolicy)
59
+
60
+
61
+ _BUILTIN_TOOL_NAMES: set[str] = {
62
+ # Agent schema-only tools (handled by adapters/runtime effect handlers).
63
+ "ask_user",
64
+ "open_attachment",
65
+ "recall_memory",
66
+ "inspect_vars",
67
+ "remember",
68
+ "remember_note",
69
+ "compact_memory",
70
+ "delegate_agent",
71
+ }
72
+
73
+
74
+ def _new_message(*, role: str, content: str, metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
75
+ msg: Dict[str, Any] = {"role": str(role), "content": str(content)}
76
+ if metadata:
77
+ msg["metadata"] = dict(metadata)
78
+ return msg
79
+
80
+
81
+ def _tool_denied_results(tool_calls: Sequence[Dict[str, Any]], *, reason: str) -> Dict[str, Any]:
82
+ results: List[Dict[str, Any]] = []
83
+ for i, tc in enumerate(tool_calls or []):
84
+ if not isinstance(tc, dict):
85
+ continue
86
+ call_id = str(tc.get("call_id") or tc.get("id") or f"call_{i}")
87
+ name = str(tc.get("name") or "")
88
+ results.append(
89
+ {
90
+ "call_id": call_id,
91
+ "runtime_call_id": tc.get("runtime_call_id"),
92
+ "name": name,
93
+ "success": False,
94
+ "output": None,
95
+ "error": reason,
96
+ }
97
+ )
98
+ return {"mode": "executed", "results": results}
99
+
100
+
101
+ def _normalize_allowed_tools(allowed_tools: Optional[Sequence[str]]) -> Optional[List[str]]:
102
+ """Normalize a per-run tool allowlist.
103
+
104
+ Notes:
105
+ - None means "no allowlist" (all tools allowed).
106
+ - When an allowlist is provided, we always include AbstractAgent built-in schema tools
107
+ so core agent functionality (ASK_USER, memory, delegation, attachments) continues to work.
108
+ """
109
+ if allowed_tools is None:
110
+ return None
111
+ allow: set[str] = {str(t).strip() for t in (allowed_tools or []) if isinstance(t, str) and str(t).strip()}
112
+ allow |= set(_BUILTIN_TOOL_NAMES)
113
+ return sorted(allow)
114
+
115
+
116
+ class AgentHost:
117
+ """A durable, local agent host with explicit tool approval boundaries."""
118
+
119
+ def __init__(
120
+ self,
121
+ config: AgentHostConfig,
122
+ *,
123
+ tools: Optional[Sequence[Callable[..., Any]]] = None,
124
+ runtime_builder: Optional[Callable[..., Any]] = None,
125
+ lazy_runtime: bool = True,
126
+ ):
127
+ self._config = config
128
+ base_tools = list(tools) if tools is not None else list(ALL_TOOLS)
129
+
130
+ # Optional AbstractCore skim tools (installed via `abstractcore[tools]`).
131
+ # These keep web triage prompt-friendly vs fetching full pages.
132
+ try:
133
+ from abstractcore.tools.common_tools import skim_url, skim_websearch # type: ignore
134
+ except Exception:
135
+ pass
136
+ else:
137
+ def _tool_name(fn: Callable[..., Any]) -> str:
138
+ td = getattr(fn, "_tool_definition", None)
139
+ name = getattr(td, "name", None) if td is not None else None
140
+ if not name:
141
+ name = getattr(fn, "__name__", None)
142
+ return str(name or "").strip()
143
+
144
+ existing_names = {_tool_name(t) for t in base_tools if callable(t) and _tool_name(t)}
145
+ for extra in (skim_websearch, skim_url):
146
+ name = _tool_name(extra)
147
+ if name and name not in existing_names:
148
+ base_tools.append(extra)
149
+ existing_names.add(name)
150
+
151
+ self._tools = base_tools
152
+ self._runtime_builder = runtime_builder
153
+ self._lazy_runtime = bool(lazy_runtime)
154
+
155
+ self._runtime_dir = Path(config.data_dir) / "runtime"
156
+ self._runtime_dir.mkdir(parents=True, exist_ok=True)
157
+ self._run_store = JsonFileRunStore(self._runtime_dir)
158
+ self._ledger_store = JsonlLedgerStore(self._runtime_dir)
159
+ self._artifact_store = FileArtifactStore(self._runtime_dir)
160
+
161
+ self._session_store = SessionStore(Path(config.data_dir) / "session.json")
162
+ snap = self._session_store.load()
163
+ if snap is None:
164
+ snap = SessionSnapshot(
165
+ session_id=f"sess_{uuid.uuid4().hex}",
166
+ actor_id=f"actor_{uuid.uuid4().hex}",
167
+ messages=[],
168
+ last_run_id=None,
169
+ )
170
+ self._session_store.save(snap)
171
+ self._snapshot = snap
172
+
173
+ self._runtime = None
174
+ self._local_tool_executor = None
175
+ self._agent = None
176
+ if not self._lazy_runtime:
177
+ self._ensure_ready()
178
+
179
+ @property
180
+ def config(self) -> AgentHostConfig:
181
+ return self._config
182
+
183
+ @property
184
+ def tool_policy(self) -> ToolApprovalPolicy:
185
+ return self._config.tool_policy
186
+
187
+ @property
188
+ def snapshot(self) -> SessionSnapshot:
189
+ return self._snapshot
190
+
191
+ @property
192
+ def tools(self) -> List[Callable[..., Any]]:
193
+ return list(self._tools)
194
+
195
+ def _ensure_ready(self, *, provider: Optional[str] = None, model: Optional[str] = None) -> None:
196
+ if self._runtime is not None and self._local_tool_executor is not None and self._agent is not None:
197
+ return
198
+
199
+ builder = self._runtime_builder or create_local_runtime
200
+ # Runtime uses passthrough tool executor so TOOL_CALLS always produces a durable wait.
201
+ self._runtime = builder(
202
+ provider=str(provider or self._config.provider),
203
+ model=str(model or self._config.model),
204
+ run_store=self._run_store,
205
+ ledger_store=self._ledger_store,
206
+ artifact_store=self._artifact_store,
207
+ tool_executor=PassthroughToolExecutor(mode="approval_required"),
208
+ )
209
+ self._local_tool_executor = MappingToolExecutor.from_tools(self._tools)
210
+ self._agent = self._create_agent()
211
+
212
+ def clear_messages(self) -> None:
213
+ """Clear the persisted transcript for the current session."""
214
+ self._set_agent_session_messages([])
215
+
216
+ def export_messages(self, path: Path) -> None:
217
+ """Export the current transcript snapshot to a JSON file."""
218
+ p = Path(path)
219
+ p.parent.mkdir(parents=True, exist_ok=True)
220
+ payload = {"messages": self._agent_session_messages()}
221
+ p.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
222
+
223
+ def import_messages(self, path: Path) -> None:
224
+ """Import a transcript snapshot from a JSON file (best-effort)."""
225
+ p = Path(path)
226
+ data = json.loads(p.read_text(encoding="utf-8"))
227
+ msgs_raw = data.get("messages") if isinstance(data, dict) else None
228
+ msgs: List[Dict[str, Any]] = []
229
+ if isinstance(msgs_raw, list):
230
+ for m in msgs_raw:
231
+ if isinstance(m, dict) and isinstance(m.get("role"), str) and "content" in m:
232
+ msgs.append(dict(m))
233
+ self._set_agent_session_messages(msgs)
234
+
235
+ def _create_agent(self) -> object:
236
+ if self._runtime is None:
237
+ raise RuntimeError("AgentHost runtime is not initialized")
238
+ kind = str(self._config.agent_kind or "react").strip().lower()
239
+ actor_id = self._snapshot.actor_id
240
+ session_id = self._snapshot.session_id
241
+ if kind == "codeact":
242
+ return CodeActAgent(
243
+ runtime=self._runtime,
244
+ tools=list(self._tools),
245
+ max_iterations=int(self._config.max_iterations),
246
+ actor_id=actor_id,
247
+ session_id=session_id,
248
+ )
249
+ if kind == "memact":
250
+ return MemActAgent(
251
+ runtime=self._runtime,
252
+ tools=list(self._tools),
253
+ max_iterations=int(self._config.max_iterations),
254
+ actor_id=actor_id,
255
+ session_id=session_id,
256
+ )
257
+ return ReactAgent(
258
+ runtime=self._runtime,
259
+ tools=list(self._tools),
260
+ max_iterations=int(self._config.max_iterations),
261
+ plan_mode=bool(self._config.plan_mode),
262
+ review_mode=bool(self._config.review_mode),
263
+ review_max_rounds=int(self._config.review_max_rounds),
264
+ actor_id=actor_id,
265
+ session_id=session_id,
266
+ )
267
+
268
+ def _persist_snapshot(self) -> None:
269
+ self._session_store.save(self._snapshot)
270
+
271
+ def _patch_workspace_scope(self, state: RunState) -> None:
272
+ ws_root = self._config.workspace_root
273
+ if ws_root is None:
274
+ return
275
+ state.vars["workspace_root"] = str(ws_root)
276
+ state.vars["workspace_access_mode"] = str(self._config.workspace_access_mode or "workspace_only")
277
+ if self._config.workspace_ignored_paths:
278
+ state.vars["workspace_ignored_paths"] = list(self._config.workspace_ignored_paths)
279
+ if self._config.workspace_allowed_paths:
280
+ state.vars["workspace_allowed_paths"] = list(self._config.workspace_allowed_paths)
281
+
282
+ # Save via the configured run store (durable, JSON-safe).
283
+ self._run_store.save(state)
284
+
285
+ def _agent_session_messages(self) -> List[Dict[str, Any]]:
286
+ return [dict(m) for m in (self._snapshot.messages or []) if isinstance(m, dict)]
287
+
288
+ def _set_agent_session_messages(self, messages: List[Dict[str, Any]]) -> None:
289
+ self._snapshot = SessionSnapshot(
290
+ session_id=self._snapshot.session_id,
291
+ actor_id=self._snapshot.actor_id,
292
+ messages=[dict(m) for m in messages if isinstance(m, dict)],
293
+ last_run_id=self._snapshot.last_run_id,
294
+ )
295
+ self._persist_snapshot()
296
+
297
+ def run_turn(
298
+ self,
299
+ *,
300
+ user_text: str,
301
+ attachments: Optional[Sequence[Any]] = None,
302
+ provider: Optional[str] = None,
303
+ model: Optional[str] = None,
304
+ system_prompt_extra: Optional[str] = None,
305
+ allowed_tools: Optional[Sequence[str]] = None,
306
+ approve_tools: Optional[Callable[[List[Dict[str, Any]]], bool]] = None,
307
+ ask_user: Optional[Callable[[WaitState], str]] = None,
308
+ ) -> Generator[Dict[str, Any], None, str]:
309
+ """Run one user turn as an agentic run.
310
+
311
+ Yields structured dict events:
312
+ - {"type":"status", ...}
313
+ - {"type":"tool_request", ...}
314
+ - {"type":"tool_result", ...}
315
+ - {"type":"assistant", ...} (final)
316
+
317
+ Returns:
318
+ The final assistant answer string.
319
+ """
320
+ text = str(user_text or "").strip()
321
+ if not text:
322
+ raise ValueError("user_text must be non-empty")
323
+
324
+ init_provider = provider.strip() if isinstance(provider, str) and provider.strip() else None
325
+ init_model = model.strip() if isinstance(model, str) and model.strip() else None
326
+ self._ensure_ready(provider=init_provider, model=init_model)
327
+
328
+ # Ensure the current user message is in the transcript, because the ReAct adapter
329
+ # sends `messages` (not `task`) for multi-turn sessions.
330
+ messages = self._agent_session_messages()
331
+ messages.append(_new_message(role="user", content=text))
332
+ self._set_agent_session_messages(messages)
333
+
334
+ # Keep the agent’s session cache aligned with the persisted snapshot.
335
+ setattr(self._agent, "session_messages", self._agent_session_messages()) # type: ignore[union-attr]
336
+
337
+ # Start the run.
338
+ start = getattr(self._agent, "start") # type: ignore[union-attr]
339
+ run_id = start(
340
+ text,
341
+ allowed_tools=_normalize_allowed_tools(allowed_tools),
342
+ attachments=list(attachments) if attachments else None,
343
+ )
344
+ self._snapshot = SessionSnapshot(
345
+ session_id=self._snapshot.session_id,
346
+ actor_id=self._snapshot.actor_id,
347
+ messages=self._snapshot.messages,
348
+ last_run_id=str(run_id),
349
+ )
350
+ self._persist_snapshot()
351
+
352
+ state = self._runtime.get_state(str(run_id)) # type: ignore[union-attr]
353
+ self._patch_workspace_scope(state)
354
+ # Per-turn routing override (MultiLocalAbstractCoreLLMClient honors these).
355
+ runtime_ns = state.vars.get("_runtime")
356
+ if not isinstance(runtime_ns, dict):
357
+ runtime_ns = {}
358
+ state.vars["_runtime"] = runtime_ns
359
+ if isinstance(provider, str) and provider.strip():
360
+ runtime_ns["provider"] = provider.strip()
361
+ if isinstance(model, str) and model.strip():
362
+ runtime_ns["model"] = model.strip()
363
+ if isinstance(system_prompt_extra, str) and system_prompt_extra.strip():
364
+ runtime_ns["system_prompt_extra"] = system_prompt_extra.strip()
365
+ self._run_store.save(state)
366
+
367
+ yield {"type": "status", "status": "thinking", "run_id": str(run_id)}
368
+
369
+ def _default_approve(tool_calls: List[Dict[str, Any]]) -> bool:
370
+ return not self._config.tool_policy.requires_approval(tool_calls)
371
+
372
+ approve_cb = approve_tools or _default_approve
373
+
374
+ # Drive the run until it completes; handle waits.
375
+ step = getattr(self._agent, "step") # type: ignore[union-attr]
376
+ while True:
377
+ state = step()
378
+ if state.status == RunStatus.RUNNING:
379
+ continue
380
+
381
+ if state.status == RunStatus.WAITING and isinstance(state.waiting, WaitState):
382
+ wait = state.waiting
383
+ # Tool approvals (delegated TOOL_CALLS).
384
+ details = wait.details if isinstance(wait.details, dict) else {}
385
+ tc = details.get("tool_calls")
386
+ tool_calls: List[Dict[str, Any]] = [dict(x) for x in tc if isinstance(x, dict)] if isinstance(tc, list) else []
387
+ if tool_calls:
388
+ yield {"type": "tool_request", "run_id": str(run_id), "wait_key": wait.wait_key, "tool_calls": tool_calls, "details": dict(details)}
389
+ approved = False
390
+ try:
391
+ approved = bool(approve_cb(tool_calls))
392
+ except Exception:
393
+ approved = False
394
+
395
+ yield {"type": "status", "status": "executing_tools" if approved else "tools_denied", "run_id": str(run_id)}
396
+
397
+ if approved:
398
+ results = self._local_tool_executor.execute(tool_calls=tool_calls) # type: ignore[union-attr]
399
+ else:
400
+ results = _tool_denied_results(tool_calls, reason="Denied by user")
401
+
402
+ yield {"type": "tool_result", "run_id": str(run_id), "wait_key": wait.wait_key, "result": dict(results)}
403
+ self._runtime.resume( # type: ignore[union-attr]
404
+ workflow=getattr(self._agent, "workflow"), # type: ignore[union-attr]
405
+ run_id=str(run_id),
406
+ wait_key=wait.wait_key,
407
+ payload=results,
408
+ max_steps=0,
409
+ )
410
+ yield {"type": "status", "status": "thinking", "run_id": str(run_id)}
411
+ continue
412
+
413
+ # ASK_USER waits.
414
+ if wait.reason == WaitReason.USER:
415
+ yield {"type": "ask_user", "run_id": str(run_id), "wait_key": wait.wait_key, "prompt": wait.prompt, "choices": wait.choices}
416
+ if ask_user is None:
417
+ raise RuntimeError("Run is waiting for user input but no ask_user callback was provided")
418
+ response_text = str(ask_user(wait))
419
+ self._runtime.resume( # type: ignore[union-attr]
420
+ workflow=getattr(self._agent, "workflow"), # type: ignore[union-attr]
421
+ run_id=str(run_id),
422
+ wait_key=wait.wait_key,
423
+ payload={"response": response_text},
424
+ max_steps=0,
425
+ )
426
+ yield {"type": "status", "status": "thinking", "run_id": str(run_id)}
427
+ continue
428
+
429
+ # Unknown wait: surface and stop.
430
+ yield {"type": "waiting", "run_id": str(run_id), "wait": wait}
431
+ raise RuntimeError(f"Run is waiting (reason={wait.reason}) and cannot be auto-resumed")
432
+
433
+ # Terminal states.
434
+ if state.status == RunStatus.COMPLETED and isinstance(state.output, dict):
435
+ answer = str(state.output.get("answer") or "")
436
+ # Sync transcript snapshot from the agent cache.
437
+ try:
438
+ updated_messages = getattr(self._agent, "session_messages", None) # type: ignore[union-attr]
439
+ if isinstance(updated_messages, list):
440
+ self._set_agent_session_messages([dict(m) for m in updated_messages if isinstance(m, dict)])
441
+ except Exception:
442
+ pass
443
+
444
+ yield {"type": "assistant", "run_id": str(run_id), "content": answer, "output": dict(state.output)}
445
+ yield {"type": "status", "status": "ready", "run_id": str(run_id)}
446
+ return answer
447
+
448
+ if state.status == RunStatus.FAILED:
449
+ yield {"type": "error", "run_id": str(run_id), "error": str(state.error or "Run failed")}
450
+ raise RuntimeError(str(state.error or "Run failed"))
451
+
452
+ if state.status == RunStatus.CANCELLED:
453
+ yield {"type": "error", "run_id": str(run_id), "error": "Run cancelled"}
454
+ raise RuntimeError("Run cancelled")
455
+
456
+ raise RuntimeError(f"Unexpected run status: {state.status}")
457
+
458
+ def resume_run(
459
+ self,
460
+ *,
461
+ run_id: str,
462
+ provider: Optional[str] = None,
463
+ model: Optional[str] = None,
464
+ approve_tools: Optional[Callable[[List[Dict[str, Any]]], bool]] = None,
465
+ ask_user: Optional[Callable[[WaitState], str]] = None,
466
+ ) -> Generator[Dict[str, Any], None, str]:
467
+ """Resume an existing run (e.g. after app restart).
468
+
469
+ This is primarily intended for runs that are currently WAITING on:
470
+ - tool approvals (TOOL_CALLS)
471
+ - user input (ASK_USER)
472
+ """
473
+ rid = str(run_id or "").strip()
474
+ if not rid:
475
+ raise ValueError("run_id must be non-empty")
476
+
477
+ init_provider = provider.strip() if isinstance(provider, str) and provider.strip() else None
478
+ init_model = model.strip() if isinstance(model, str) and model.strip() else None
479
+ self._ensure_ready(provider=init_provider, model=init_model)
480
+
481
+ attach = getattr(self._agent, "attach", None) # type: ignore[union-attr]
482
+ if not callable(attach):
483
+ raise RuntimeError("Agent does not support attach()")
484
+ attach(rid)
485
+
486
+ self._snapshot = SessionSnapshot(
487
+ session_id=self._snapshot.session_id,
488
+ actor_id=self._snapshot.actor_id,
489
+ messages=self._snapshot.messages,
490
+ last_run_id=rid,
491
+ )
492
+ self._persist_snapshot()
493
+
494
+ try:
495
+ state = self._runtime.get_state(rid) # type: ignore[union-attr]
496
+ self._patch_workspace_scope(state)
497
+ except Exception:
498
+ pass
499
+
500
+ yield {"type": "status", "status": "thinking", "run_id": rid}
501
+
502
+ def _default_approve(tool_calls: List[Dict[str, Any]]) -> bool:
503
+ return not self._config.tool_policy.requires_approval(tool_calls)
504
+
505
+ approve_cb = approve_tools or _default_approve
506
+ step = getattr(self._agent, "step") # type: ignore[union-attr]
507
+
508
+ while True:
509
+ state = step()
510
+ if state.status == RunStatus.RUNNING:
511
+ continue
512
+
513
+ if state.status == RunStatus.WAITING and isinstance(state.waiting, WaitState):
514
+ wait = state.waiting
515
+ details = wait.details if isinstance(wait.details, dict) else {}
516
+ tc = details.get("tool_calls")
517
+ tool_calls: List[Dict[str, Any]] = [dict(x) for x in tc if isinstance(x, dict)] if isinstance(tc, list) else []
518
+ if tool_calls:
519
+ yield {"type": "tool_request", "run_id": rid, "wait_key": wait.wait_key, "tool_calls": tool_calls, "details": dict(details)}
520
+ approved = False
521
+ try:
522
+ approved = bool(approve_cb(tool_calls))
523
+ except Exception:
524
+ approved = False
525
+
526
+ yield {"type": "status", "status": "executing_tools" if approved else "tools_denied", "run_id": rid}
527
+
528
+ if approved:
529
+ results = self._local_tool_executor.execute(tool_calls=tool_calls) # type: ignore[union-attr]
530
+ else:
531
+ results = _tool_denied_results(tool_calls, reason="Denied by user")
532
+
533
+ yield {"type": "tool_result", "run_id": rid, "wait_key": wait.wait_key, "result": dict(results)}
534
+ self._runtime.resume( # type: ignore[union-attr]
535
+ workflow=getattr(self._agent, "workflow"), # type: ignore[union-attr]
536
+ run_id=rid,
537
+ wait_key=wait.wait_key,
538
+ payload=results,
539
+ max_steps=0,
540
+ )
541
+ yield {"type": "status", "status": "thinking", "run_id": rid}
542
+ continue
543
+
544
+ if wait.reason == WaitReason.USER:
545
+ yield {"type": "ask_user", "run_id": rid, "wait_key": wait.wait_key, "prompt": wait.prompt, "choices": wait.choices}
546
+ if ask_user is None:
547
+ raise RuntimeError("Run is waiting for user input but no ask_user callback was provided")
548
+ response_text = str(ask_user(wait))
549
+ self._runtime.resume( # type: ignore[union-attr]
550
+ workflow=getattr(self._agent, "workflow"), # type: ignore[union-attr]
551
+ run_id=rid,
552
+ wait_key=wait.wait_key,
553
+ payload={"response": response_text},
554
+ max_steps=0,
555
+ )
556
+ yield {"type": "status", "status": "thinking", "run_id": rid}
557
+ continue
558
+
559
+ yield {"type": "waiting", "run_id": rid, "wait": wait}
560
+ raise RuntimeError(f"Run is waiting (reason={wait.reason}) and cannot be auto-resumed")
561
+
562
+ if state.status == RunStatus.COMPLETED and isinstance(state.output, dict):
563
+ answer = str(state.output.get("answer") or "")
564
+ try:
565
+ updated_messages = getattr(self._agent, "session_messages", None) # type: ignore[union-attr]
566
+ if isinstance(updated_messages, list):
567
+ self._set_agent_session_messages([dict(m) for m in updated_messages if isinstance(m, dict)])
568
+ except Exception:
569
+ pass
570
+
571
+ yield {"type": "assistant", "run_id": rid, "content": answer, "output": dict(state.output)}
572
+ yield {"type": "status", "status": "ready", "run_id": rid}
573
+ return answer
574
+
575
+ if state.status == RunStatus.FAILED:
576
+ yield {"type": "error", "run_id": rid, "error": str(state.error or "Run failed")}
577
+ raise RuntimeError(str(state.error or "Run failed"))
578
+
579
+ if state.status == RunStatus.CANCELLED:
580
+ yield {"type": "error", "run_id": rid, "error": "Run cancelled"}
581
+ raise RuntimeError("Run cancelled")
582
+
583
+ raise RuntimeError(f"Unexpected run status: {state.status}")