abstractassistant 0.3.4__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.
- abstractassistant/app.py +69 -6
- abstractassistant/cli.py +104 -85
- abstractassistant/core/agent_host.py +583 -0
- abstractassistant/core/llm_manager.py +338 -431
- abstractassistant/core/session_index.py +293 -0
- abstractassistant/core/session_store.py +79 -0
- abstractassistant/core/tool_policy.py +58 -0
- abstractassistant/core/transcript_summary.py +434 -0
- abstractassistant/ui/history_dialog.py +504 -29
- abstractassistant/ui/provider_manager.py +2 -2
- abstractassistant/ui/qt_bubble.py +2289 -489
- abstractassistant-0.4.0.dist-info/METADATA +168 -0
- abstractassistant-0.4.0.dist-info/RECORD +32 -0
- {abstractassistant-0.3.4.dist-info → abstractassistant-0.4.0.dist-info}/WHEEL +1 -1
- {abstractassistant-0.3.4.dist-info → abstractassistant-0.4.0.dist-info}/entry_points.txt +1 -0
- abstractassistant-0.3.4.dist-info/METADATA +0 -297
- abstractassistant-0.3.4.dist-info/RECORD +0 -27
- {abstractassistant-0.3.4.dist-info → abstractassistant-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {abstractassistant-0.3.4.dist-info → abstractassistant-0.4.0.dist-info}/top_level.txt +0 -0
|
@@ -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}")
|