abstractagent 0.2.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,421 @@
1
+ """Base agent class with common functionality.
2
+
3
+ All agent types (ReAct, CodeAct, etc.) inherit from BaseAgent to get:
4
+ - Runtime access (ledger, run store, cancel)
5
+ - State management (save/load/attach)
6
+ - Async message injection
7
+ - Common lifecycle methods
8
+ """
9
+
10
+ from abc import ABC, abstractmethod
11
+ from typing import Any, Callable, Dict, List, Optional
12
+
13
+ from abstractruntime import Runtime, RunState, RunStatus, WorkflowSpec
14
+
15
+
16
+ class BaseAgent(ABC):
17
+ """Abstract base class for all agent types.
18
+
19
+ Provides common functionality that all agents need:
20
+ - Runtime integration
21
+ - State persistence
22
+ - Cancellation
23
+ - Ledger access
24
+ - Async message injection
25
+
26
+ Subclasses must implement:
27
+ - _create_workflow(): Return the WorkflowSpec for this agent type
28
+ - start(): Initialize and start a run
29
+ - step(): Execute one step
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ runtime: Runtime,
35
+ tools: Optional[List[Callable]] = None,
36
+ on_step: Optional[Callable[[str, Dict[str, Any]], None]] = None,
37
+ *,
38
+ actor_id: Optional[str] = None,
39
+ session_id: Optional[str] = None,
40
+ ):
41
+ """Initialize the agent.
42
+
43
+ Args:
44
+ runtime: AbstractRuntime instance for durable execution
45
+ tools: List of tool functions decorated with @tool
46
+ on_step: Optional callback for step visibility (step_name, data)
47
+ """
48
+ self.runtime = runtime
49
+ self.tools = tools or []
50
+ self.on_step = on_step
51
+ self.workflow = self._create_workflow()
52
+ self._current_run_id: Optional[str] = None
53
+ self.actor_id: Optional[str] = actor_id
54
+ self.session_id: Optional[str] = session_id
55
+ self.session_messages: List[Dict[str, Any]] = []
56
+
57
+ def _ensure_actor_id(self) -> str:
58
+ if self.actor_id:
59
+ return self.actor_id
60
+
61
+ from abstractruntime.identity.fingerprint import ActorFingerprint
62
+ import uuid
63
+
64
+ fp = ActorFingerprint.from_metadata(
65
+ kind="agent",
66
+ display_name=self.__class__.__name__,
67
+ metadata={"nonce": uuid.uuid4().hex},
68
+ )
69
+ self.actor_id = fp.actor_id
70
+ return self.actor_id
71
+
72
+ def _ensure_session_id(self) -> str:
73
+ if self.session_id:
74
+ return self.session_id
75
+ import uuid
76
+
77
+ self.session_id = f"sess_{uuid.uuid4().hex}"
78
+ return self.session_id
79
+
80
+ @abstractmethod
81
+ def _create_workflow(self) -> WorkflowSpec:
82
+ """Create the workflow specification for this agent type.
83
+
84
+ Returns:
85
+ WorkflowSpec defining the agent's execution graph
86
+ """
87
+ pass
88
+
89
+ @abstractmethod
90
+ def start(self, task: str) -> str:
91
+ """Start a new agent run with a task.
92
+
93
+ Args:
94
+ task: The task description for the agent
95
+
96
+ Returns:
97
+ The run_id for this execution
98
+ """
99
+ pass
100
+
101
+ @abstractmethod
102
+ def step(self) -> RunState:
103
+ """Execute one step of the agent.
104
+
105
+ Returns:
106
+ Current RunState after the step
107
+ """
108
+ pass
109
+
110
+ # -------------------------------------------------------------------------
111
+ # Common methods (inherited by all agent types)
112
+ # -------------------------------------------------------------------------
113
+
114
+ def run_to_completion(self) -> RunState:
115
+ """Run the agent until completion or waiting.
116
+
117
+ Returns:
118
+ Final RunState (COMPLETED, FAILED, or WAITING)
119
+ """
120
+ if not self._current_run_id:
121
+ raise RuntimeError("No active run. Call start() first.")
122
+
123
+ state = self.step()
124
+ while state.status == RunStatus.RUNNING:
125
+ state = self.step()
126
+
127
+ return state
128
+
129
+ def get_state(self) -> Optional[RunState]:
130
+ """Get current agent state.
131
+
132
+ Returns:
133
+ Current RunState or None if no active run
134
+ """
135
+ if not self._current_run_id:
136
+ return None
137
+ return self.runtime.get_state(self._current_run_id)
138
+
139
+ def is_waiting(self) -> bool:
140
+ """Check if agent is waiting for input.
141
+
142
+ Returns:
143
+ True if agent is in WAITING status
144
+ """
145
+ state = self.get_state()
146
+ return state is not None and state.status == RunStatus.WAITING
147
+
148
+ def is_running(self) -> bool:
149
+ """Check if agent is actively running.
150
+
151
+ Returns:
152
+ True if agent is in RUNNING status
153
+ """
154
+ state = self.get_state()
155
+ return state is not None and state.status == RunStatus.RUNNING
156
+
157
+ def is_complete(self) -> bool:
158
+ """Check if agent has completed.
159
+
160
+ Returns:
161
+ True if agent is in COMPLETED status
162
+ """
163
+ state = self.get_state()
164
+ return state is not None and state.status == RunStatus.COMPLETED
165
+
166
+ def get_pending_question(self) -> Optional[Dict[str, Any]]:
167
+ """Get pending question if agent is waiting for user input.
168
+
169
+ Returns:
170
+ Dict with prompt, choices, allow_free_text, wait_key
171
+ or None if not waiting
172
+ """
173
+ state = self.get_state()
174
+ if not state or state.status != RunStatus.WAITING or not state.waiting:
175
+ return None
176
+
177
+ return {
178
+ "prompt": state.waiting.prompt,
179
+ "choices": state.waiting.choices,
180
+ "allow_free_text": state.waiting.allow_free_text,
181
+ "wait_key": state.waiting.wait_key,
182
+ }
183
+
184
+ def resume(self, response: str) -> RunState:
185
+ """Resume agent with user response.
186
+
187
+ Args:
188
+ response: User's answer to the pending question
189
+
190
+ Returns:
191
+ Updated RunState after resuming
192
+ """
193
+ if not self._current_run_id:
194
+ raise RuntimeError("No active run.")
195
+
196
+ state = self.get_state()
197
+ if not state or state.status != RunStatus.WAITING:
198
+ raise RuntimeError("Agent is not waiting for input.")
199
+
200
+ wait_key = state.waiting.wait_key if state.waiting else None
201
+
202
+ return self.runtime.resume(
203
+ workflow=self.workflow,
204
+ run_id=self._current_run_id,
205
+ wait_key=wait_key,
206
+ payload={"response": response},
207
+ )
208
+
209
+ def attach(self, run_id: str) -> RunState:
210
+ """Attach to an existing run for resume.
211
+
212
+ Args:
213
+ run_id: ID of the run to attach to
214
+
215
+ Returns:
216
+ Current RunState
217
+ """
218
+ state = self.runtime.get_state(run_id)
219
+ if state.workflow_id != self.workflow.workflow_id:
220
+ raise ValueError(
221
+ f"Run workflow_id mismatch: run has '{state.workflow_id}', "
222
+ f"agent expects '{self.workflow.workflow_id}'."
223
+ )
224
+
225
+ if self.actor_id and state.actor_id and self.actor_id != state.actor_id:
226
+ raise ValueError(
227
+ f"Run actor_id mismatch: run has '{state.actor_id}', agent expects '{self.actor_id}'."
228
+ )
229
+ if self.actor_id is None and state.actor_id:
230
+ self.actor_id = state.actor_id
231
+
232
+ state_session_id = getattr(state, "session_id", None)
233
+ if self.session_id and state_session_id and self.session_id != state_session_id:
234
+ raise ValueError(
235
+ f"Run session_id mismatch: run has '{state_session_id}', agent expects '{self.session_id}'."
236
+ )
237
+ if self.session_id is None and state_session_id:
238
+ self.session_id = state_session_id
239
+
240
+ self._current_run_id = run_id
241
+ return state
242
+
243
+ def save_state(self, filepath: str) -> None:
244
+ """Save a run reference to file for later resume.
245
+
246
+ The full durable state is owned and persisted by AbstractRuntime's RunStore.
247
+ This method only stores the identifiers needed to re-attach on restart.
248
+
249
+ Args:
250
+ filepath: Path to save state file
251
+ """
252
+ import json
253
+ from pathlib import Path
254
+ from abstractruntime.storage.in_memory import InMemoryRunStore
255
+
256
+ if not self._current_run_id:
257
+ raise RuntimeError("No active run to save.")
258
+
259
+ if isinstance(self.runtime.run_store, InMemoryRunStore):
260
+ raise RuntimeError(
261
+ "save_state requires a persistent RunStore (e.g. JsonFileRunStore); "
262
+ "the current runtime uses InMemoryRunStore which cannot resume across restarts."
263
+ )
264
+
265
+ data = {
266
+ "run_id": self._current_run_id,
267
+ "workflow_id": self.workflow.workflow_id,
268
+ "actor_id": self.actor_id,
269
+ "session_id": self._ensure_session_id(),
270
+ }
271
+
272
+ Path(filepath).write_text(json.dumps(data, indent=2))
273
+
274
+ def load_state(self, filepath: str) -> Optional[RunState]:
275
+ """Load run reference from file and attach to it.
276
+
277
+ The full durable state is loaded from AbstractRuntime's RunStore.
278
+
279
+ Args:
280
+ filepath: Path to state file
281
+
282
+ Returns:
283
+ RunState if found and valid, None otherwise
284
+ """
285
+ import json
286
+ from pathlib import Path
287
+
288
+ path = Path(filepath)
289
+ if not path.exists():
290
+ return None
291
+
292
+ data = json.loads(path.read_text())
293
+ run_id = data.get("run_id")
294
+ if not run_id:
295
+ return None
296
+
297
+ workflow_id = data.get("workflow_id")
298
+ if workflow_id and workflow_id != self.workflow.workflow_id:
299
+ raise ValueError(
300
+ f"Saved workflow_id mismatch: file has '{workflow_id}', "
301
+ f"agent expects '{self.workflow.workflow_id}'."
302
+ )
303
+
304
+ actor_id = data.get("actor_id")
305
+ if actor_id and self.actor_id and actor_id != self.actor_id:
306
+ raise ValueError(
307
+ f"Saved actor_id mismatch: file has '{actor_id}', agent expects '{self.actor_id}'."
308
+ )
309
+ if actor_id and self.actor_id is None:
310
+ self.actor_id = actor_id
311
+
312
+ session_id = data.get("session_id")
313
+ if session_id and self.session_id and session_id != self.session_id:
314
+ raise ValueError(
315
+ f"Saved session_id mismatch: file has '{session_id}', agent expects '{self.session_id}'."
316
+ )
317
+ if session_id and self.session_id is None:
318
+ self.session_id = session_id
319
+
320
+ try:
321
+ return self.attach(str(run_id))
322
+ except KeyError as e:
323
+ raise RuntimeError(
324
+ f"Saved run_id '{run_id}' was not found in the configured RunStore. "
325
+ "If you deleted/moved the store directory, this run cannot be resumed."
326
+ ) from e
327
+
328
+ def clear_state(self, filepath: str) -> None:
329
+ """Remove state file after completion.
330
+
331
+ Args:
332
+ filepath: Path to state file
333
+ """
334
+ from pathlib import Path
335
+ Path(filepath).unlink(missing_ok=True)
336
+
337
+ @property
338
+ def run_id(self) -> Optional[str]:
339
+ """Get the current run ID."""
340
+ return self._current_run_id
341
+
342
+ def cancel(self, reason: Optional[str] = None) -> RunState:
343
+ """Cancel the current run.
344
+
345
+ Args:
346
+ reason: Optional cancellation reason
347
+
348
+ Returns:
349
+ Updated RunState with CANCELLED status
350
+ """
351
+ if not self._current_run_id:
352
+ raise RuntimeError("No active run to cancel.")
353
+
354
+ return self.runtime.cancel_run(self._current_run_id, reason=reason)
355
+
356
+ def get_ledger(self) -> list:
357
+ """Get ledger entries for the current run.
358
+
359
+ Returns:
360
+ List of ledger entries (dicts with effect details)
361
+ """
362
+ if not self._current_run_id:
363
+ return []
364
+
365
+ return self.runtime.get_ledger(self._current_run_id)
366
+
367
+ def inject_message(self, message: str) -> None:
368
+ """Inject a message into the agent's inbox for next iteration.
369
+
370
+ The agent will see this message on its next reasoning step.
371
+ Useful for providing guidance or additional context while running.
372
+
373
+ Args:
374
+ message: Message to inject
375
+ """
376
+ if not self._current_run_id:
377
+ raise RuntimeError("No active run.")
378
+
379
+ state = self.runtime.get_state(self._current_run_id)
380
+ runtime_ns = state.vars.get("_runtime")
381
+ if not isinstance(runtime_ns, dict):
382
+ runtime_ns = {}
383
+ state.vars["_runtime"] = runtime_ns
384
+
385
+ inbox = runtime_ns.get("inbox")
386
+ if not isinstance(inbox, list):
387
+ legacy = state.vars.get("_inbox")
388
+ inbox = legacy if isinstance(legacy, list) else []
389
+ runtime_ns["inbox"] = inbox
390
+
391
+ inbox.append(
392
+ {
393
+ "type": "user_guidance",
394
+ "content": message,
395
+ "timestamp": self.runtime._ctx.now_iso() if hasattr(self.runtime._ctx, "now_iso") else None,
396
+ }
397
+ )
398
+ state.vars.pop("_inbox", None)
399
+ self.runtime._run_store.save(state)
400
+
401
+ def get_output(self) -> Optional[Dict[str, Any]]:
402
+ """Get the output from a completed run.
403
+
404
+ Returns:
405
+ Output dict if completed, None otherwise
406
+ """
407
+ state = self.get_state()
408
+ if state and state.status == RunStatus.COMPLETED:
409
+ return state.output
410
+ return None
411
+
412
+ def get_error(self) -> Optional[str]:
413
+ """Get the error from a failed run.
414
+
415
+ Returns:
416
+ Error string if failed, None otherwise
417
+ """
418
+ state = self.get_state()
419
+ if state and state.status == RunStatus.FAILED:
420
+ return state.error
421
+ return None
@@ -0,0 +1,194 @@
1
+ """CodeAct agent implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable, Dict, List, Optional
6
+
7
+ from abstractcore.tools import ToolDefinition
8
+ from abstractruntime import RunState, Runtime, WorkflowSpec
9
+
10
+ from .base import BaseAgent
11
+ from ..adapters.codeact_runtime import create_codeact_workflow
12
+ from ..logic.builtins import ASK_USER_TOOL
13
+ from ..logic.codeact import CodeActLogic
14
+
15
+
16
+ def _tool_definitions_from_callables(tools: List[Callable[..., Any]]) -> List[ToolDefinition]:
17
+ tool_defs: List[ToolDefinition] = []
18
+ for t in tools:
19
+ tool_def = getattr(t, "_tool_definition", None)
20
+ if tool_def is None:
21
+ tool_def = ToolDefinition.from_function(t)
22
+ tool_defs.append(tool_def)
23
+ return tool_defs
24
+
25
+
26
+ def _copy_messages(messages: Any) -> List[Dict[str, Any]]:
27
+ if not isinstance(messages, list):
28
+ return []
29
+ out: List[Dict[str, Any]] = []
30
+ for m in messages:
31
+ if isinstance(m, dict):
32
+ out.append(dict(m))
33
+ return out
34
+
35
+
36
+ class CodeActAgent(BaseAgent):
37
+ """Agent that primarily acts by executing Python code snippets."""
38
+
39
+ def __init__(
40
+ self,
41
+ *,
42
+ runtime: Runtime,
43
+ tools: Optional[List[Callable[..., Any]]] = None,
44
+ on_step: Optional[Callable[[str, Dict[str, Any]], None]] = None,
45
+ max_iterations: int = 25,
46
+ max_history_messages: int = -1,
47
+ max_tokens: Optional[int] = 32768,
48
+ actor_id: Optional[str] = None,
49
+ session_id: Optional[str] = None,
50
+ ):
51
+ self._max_iterations = int(max_iterations)
52
+ if self._max_iterations < 1:
53
+ self._max_iterations = 1
54
+ self._max_history_messages = int(max_history_messages)
55
+ # -1 means unlimited (send all messages), otherwise must be >= 1
56
+ if self._max_history_messages != -1 and self._max_history_messages < 1:
57
+ self._max_history_messages = 1
58
+ self._max_tokens = max_tokens
59
+
60
+ self.logic: Optional[CodeActLogic] = None
61
+ super().__init__(
62
+ runtime=runtime,
63
+ tools=tools,
64
+ on_step=on_step,
65
+ actor_id=actor_id,
66
+ session_id=session_id,
67
+ )
68
+
69
+ def _create_workflow(self) -> WorkflowSpec:
70
+ tool_defs = _tool_definitions_from_callables(self.tools)
71
+ tool_defs = [ASK_USER_TOOL, *tool_defs]
72
+ logic = CodeActLogic(
73
+ tools=tool_defs,
74
+ max_history_messages=self._max_history_messages,
75
+ max_tokens=self._max_tokens,
76
+ )
77
+ self.logic = logic
78
+ return create_codeact_workflow(logic=logic, on_step=self.on_step)
79
+
80
+ def start(self, task: str) -> str:
81
+ task = str(task or "").strip()
82
+ if not task:
83
+ raise ValueError("task must be a non-empty string")
84
+
85
+ vars: Dict[str, Any] = {
86
+ "context": {"task": task, "messages": _copy_messages(self.session_messages)},
87
+ "scratchpad": {"iteration": 0, "max_iterations": int(self._max_iterations)},
88
+ "_runtime": {"inbox": []},
89
+ "_temp": {},
90
+ # Canonical _limits namespace for runtime awareness
91
+ "_limits": {
92
+ "max_iterations": int(self._max_iterations),
93
+ "current_iteration": 0,
94
+ "max_tokens": self._max_tokens,
95
+ "max_history_messages": int(self._max_history_messages),
96
+ "estimated_tokens_used": 0,
97
+ "warn_iterations_pct": 80,
98
+ "warn_tokens_pct": 80,
99
+ },
100
+ }
101
+
102
+ run_id = self.runtime.start(
103
+ workflow=self.workflow,
104
+ vars=vars,
105
+ actor_id=self._ensure_actor_id(),
106
+ session_id=self._ensure_session_id(),
107
+ )
108
+ self._current_run_id = run_id
109
+ return run_id
110
+
111
+ def get_limit_status(self) -> Dict[str, Any]:
112
+ """Get current limit status for the active run.
113
+
114
+ Returns a structured dict with information about iterations, tokens,
115
+ and history limits, including whether warning thresholds are reached.
116
+
117
+ Returns:
118
+ Dict with "iterations", "tokens", and "history" status info,
119
+ or empty dict if no active run.
120
+ """
121
+ if self._current_run_id is None:
122
+ return {}
123
+ return self.runtime.get_limit_status(self._current_run_id)
124
+
125
+ def update_limits(self, **updates: Any) -> None:
126
+ """Update limits mid-session.
127
+
128
+ Only allowed limit keys are updated; unknown keys are ignored.
129
+ Allowed keys: max_iterations, max_tokens, max_output_tokens,
130
+ max_history_messages, warn_iterations_pct, warn_tokens_pct.
131
+
132
+ Args:
133
+ **updates: Limit key-value pairs to update
134
+
135
+ Raises:
136
+ RuntimeError: If no active run
137
+ """
138
+ if self._current_run_id is None:
139
+ raise RuntimeError("No active run. Call start() first.")
140
+ self.runtime.update_limits(self._current_run_id, updates)
141
+
142
+ def step(self) -> RunState:
143
+ if not self._current_run_id:
144
+ raise RuntimeError("No active run. Call start() first.")
145
+ return self.runtime.tick(workflow=self.workflow, run_id=self._current_run_id, max_steps=1)
146
+
147
+
148
+ def create_codeact_agent(
149
+ *,
150
+ provider: str = "ollama",
151
+ model: str = "qwen3:1.7b-q4_K_M",
152
+ tools: Optional[List[Callable[..., Any]]] = None,
153
+ on_step: Optional[Callable[[str, Dict[str, Any]], None]] = None,
154
+ max_iterations: int = 25,
155
+ max_history_messages: int = -1,
156
+ max_tokens: Optional[int] = 32768,
157
+ llm_kwargs: Optional[Dict[str, Any]] = None,
158
+ run_store: Optional[Any] = None,
159
+ ledger_store: Optional[Any] = None,
160
+ actor_id: Optional[str] = None,
161
+ session_id: Optional[str] = None,
162
+ ) -> CodeActAgent:
163
+ """Factory: create a CodeActAgent with a local AbstractCore-backed runtime."""
164
+
165
+ from abstractruntime.integrations.abstractcore import MappingToolExecutor, create_local_runtime
166
+
167
+ if tools is None:
168
+ from ..tools.code_execution import execute_python
169
+
170
+ tools = [execute_python]
171
+
172
+ runtime = create_local_runtime(
173
+ provider=provider,
174
+ model=model,
175
+ llm_kwargs=llm_kwargs,
176
+ run_store=run_store,
177
+ ledger_store=ledger_store,
178
+ tool_executor=MappingToolExecutor.from_tools(list(tools)),
179
+ )
180
+
181
+ return CodeActAgent(
182
+ runtime=runtime,
183
+ tools=list(tools),
184
+ on_step=on_step,
185
+ max_iterations=max_iterations,
186
+ max_history_messages=max_history_messages,
187
+ max_tokens=max_tokens,
188
+ actor_id=actor_id,
189
+ session_id=session_id,
190
+ )
191
+
192
+
193
+ __all__ = ["CodeActAgent", "create_codeact_workflow", "create_codeact_agent"]
194
+