abstractagent 0.2.0__py3-none-any.whl → 0.3.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.
@@ -3,6 +3,7 @@
3
3
  from .base import BaseAgent
4
4
  from .react import ReactAgent, create_react_workflow, create_react_agent
5
5
  from .codeact import CodeActAgent, create_codeact_workflow, create_codeact_agent
6
+ from .memact import MemActAgent, create_memact_workflow, create_memact_agent
6
7
 
7
8
  __all__ = [
8
9
  "BaseAgent",
@@ -12,4 +13,7 @@ __all__ = [
12
13
  "CodeActAgent",
13
14
  "create_codeact_workflow",
14
15
  "create_codeact_agent",
16
+ "MemActAgent",
17
+ "create_memact_workflow",
18
+ "create_memact_agent",
15
19
  ]
@@ -49,11 +49,58 @@ class BaseAgent(ABC):
49
49
  self.tools = tools or []
50
50
  self.on_step = on_step
51
51
  self.workflow = self._create_workflow()
52
+ self._ensure_workflow_registered()
52
53
  self._current_run_id: Optional[str] = None
53
54
  self.actor_id: Optional[str] = actor_id
54
55
  self.session_id: Optional[str] = session_id
55
56
  self.session_messages: List[Dict[str, Any]] = []
56
57
 
58
+ def _ensure_workflow_registered(self) -> None:
59
+ """Ensure this agent's workflow is registered for subworkflow composition.
60
+
61
+ START_SUBWORKFLOW requires a runtime workflow_registry. Registering here keeps
62
+ agent-created runtimes usable for delegation/subflows without extra host wiring.
63
+ """
64
+ try:
65
+ wf = getattr(self, "workflow", None)
66
+ wid = getattr(wf, "workflow_id", None)
67
+ if not isinstance(wid, str) or not wid.strip():
68
+ return
69
+
70
+ reg = getattr(self.runtime, "workflow_registry", None)
71
+ if reg is None:
72
+ from abstractruntime.scheduler.registry import WorkflowRegistry
73
+
74
+ reg = WorkflowRegistry()
75
+ setter = getattr(self.runtime, "set_workflow_registry", None)
76
+ if callable(setter):
77
+ setter(reg)
78
+ getter = getattr(reg, "get", None)
79
+ if callable(getter) and getter(wid) is not None:
80
+ return
81
+ register = getattr(reg, "register", None)
82
+ if callable(register):
83
+ register(wf)
84
+ except Exception:
85
+ # Never block agent creation due to registry wiring; hosts can still inject their own registry.
86
+ return
87
+
88
+ def _sync_session_caches_from_state(self, state: Optional[RunState]) -> None:
89
+ if state is None or not hasattr(state, "vars") or not isinstance(state.vars, dict):
90
+ return
91
+
92
+ messages: Optional[List[Dict[str, Any]]] = None
93
+ output = getattr(state, "output", None)
94
+ if isinstance(output, dict) and isinstance(output.get("messages"), list):
95
+ messages = [dict(m) for m in output["messages"] if isinstance(m, dict)]
96
+ else:
97
+ ctx = state.vars.get("context")
98
+ if isinstance(ctx, dict) and isinstance(ctx.get("messages"), list):
99
+ messages = [dict(m) for m in ctx["messages"] if isinstance(m, dict)]
100
+
101
+ if messages is not None:
102
+ self.session_messages = list(messages)
103
+
57
104
  def _ensure_actor_id(self) -> str:
58
105
  if self.actor_id:
59
106
  return self.actor_id
@@ -135,6 +182,43 @@ class BaseAgent(ABC):
135
182
  if not self._current_run_id:
136
183
  return None
137
184
  return self.runtime.get_state(self._current_run_id)
185
+
186
+ def get_context(self) -> Dict[str, Any]:
187
+ """Get the agent's current context namespace (runtime-owned persisted state)."""
188
+ state = self.get_state()
189
+ ctx = state.vars.get("context") if state and hasattr(state, "vars") else None
190
+ return dict(ctx) if isinstance(ctx, dict) else {}
191
+
192
+ def get_scratchpad(self) -> Dict[str, Any]:
193
+ """Get the agent's current scratchpad namespace (agent-owned schema, runtime-owned storage)."""
194
+ state = self.get_state()
195
+ scratch = state.vars.get("scratchpad") if state and hasattr(state, "vars") else None
196
+ return dict(scratch) if isinstance(scratch, dict) else {}
197
+
198
+ def get_node_traces(self) -> Dict[str, Any]:
199
+ """Get runtime-owned per-node traces for the current run (passthrough to Runtime)."""
200
+ if not self._current_run_id:
201
+ return {}
202
+ getter = getattr(self.runtime, "get_node_traces", None)
203
+ if callable(getter):
204
+ return getter(self._current_run_id)
205
+ state = self.get_state()
206
+ runtime_ns = state.vars.get("_runtime") if state and hasattr(state, "vars") else None
207
+ traces = runtime_ns.get("node_traces") if isinstance(runtime_ns, dict) else None
208
+ return dict(traces) if isinstance(traces, dict) else {}
209
+
210
+ def get_node_trace(self, node_id: str) -> Dict[str, Any]:
211
+ """Get a single runtime-owned node trace for the current run."""
212
+ if not self._current_run_id:
213
+ return {"node_id": node_id, "steps": []}
214
+ getter = getattr(self.runtime, "get_node_trace", None)
215
+ if callable(getter):
216
+ return getter(self._current_run_id, node_id)
217
+ traces = self.get_node_traces()
218
+ trace = traces.get(node_id)
219
+ if isinstance(trace, dict):
220
+ return trace
221
+ return {"node_id": node_id, "steps": []}
138
222
 
139
223
  def is_waiting(self) -> bool:
140
224
  """Check if agent is waiting for input.
@@ -199,12 +283,15 @@ class BaseAgent(ABC):
199
283
 
200
284
  wait_key = state.waiting.wait_key if state.waiting else None
201
285
 
202
- return self.runtime.resume(
286
+ state2 = self.runtime.resume(
203
287
  workflow=self.workflow,
204
288
  run_id=self._current_run_id,
205
289
  wait_key=wait_key,
206
290
  payload={"response": response},
207
291
  )
292
+ if state2.status in (RunStatus.COMPLETED, RunStatus.FAILED, RunStatus.CANCELLED):
293
+ self._sync_session_caches_from_state(state2)
294
+ return state2
208
295
 
209
296
  def attach(self, run_id: str) -> RunState:
210
297
  """Attach to an existing run for resume.
@@ -238,6 +325,7 @@ class BaseAgent(ABC):
238
325
  self.session_id = state_session_id
239
326
 
240
327
  self._current_run_id = run_id
328
+ self._sync_session_caches_from_state(state)
241
329
  return state
242
330
 
243
331
  def save_state(self, filepath: str) -> None:
@@ -5,11 +5,20 @@ from __future__ import annotations
5
5
  from typing import Any, Callable, Dict, List, Optional
6
6
 
7
7
  from abstractcore.tools import ToolDefinition
8
- from abstractruntime import RunState, Runtime, WorkflowSpec
8
+ from abstractruntime import RunState, RunStatus, Runtime, WorkflowSpec
9
9
 
10
10
  from .base import BaseAgent
11
11
  from ..adapters.codeact_runtime import create_codeact_workflow
12
- from ..logic.builtins import ASK_USER_TOOL
12
+ from ..logic.builtins import (
13
+ ASK_USER_TOOL,
14
+ COMPACT_MEMORY_TOOL,
15
+ DELEGATE_AGENT_TOOL,
16
+ INSPECT_VARS_TOOL,
17
+ OPEN_ATTACHMENT_TOOL,
18
+ RECALL_MEMORY_TOOL,
19
+ REMEMBER_TOOL,
20
+ REMEMBER_NOTE_TOOL,
21
+ )
13
22
  from ..logic.codeact import CodeActLogic
14
23
 
15
24
 
@@ -44,7 +53,10 @@ class CodeActAgent(BaseAgent):
44
53
  on_step: Optional[Callable[[str, Dict[str, Any]], None]] = None,
45
54
  max_iterations: int = 25,
46
55
  max_history_messages: int = -1,
47
- max_tokens: Optional[int] = 32768,
56
+ max_tokens: Optional[int] = None,
57
+ plan_mode: bool = False,
58
+ review_mode: bool = True,
59
+ review_max_rounds: int = 3,
48
60
  actor_id: Optional[str] = None,
49
61
  session_id: Optional[str] = None,
50
62
  ):
@@ -56,6 +68,11 @@ class CodeActAgent(BaseAgent):
56
68
  if self._max_history_messages != -1 and self._max_history_messages < 1:
57
69
  self._max_history_messages = 1
58
70
  self._max_tokens = max_tokens
71
+ self._plan_mode = bool(plan_mode)
72
+ self._review_mode = bool(review_mode)
73
+ self._review_max_rounds = int(review_max_rounds)
74
+ if self._review_max_rounds < 0:
75
+ self._review_max_rounds = 0
59
76
 
60
77
  self.logic: Optional[CodeActLogic] = None
61
78
  super().__init__(
@@ -68,7 +85,17 @@ class CodeActAgent(BaseAgent):
68
85
 
69
86
  def _create_workflow(self) -> WorkflowSpec:
70
87
  tool_defs = _tool_definitions_from_callables(self.tools)
71
- tool_defs = [ASK_USER_TOOL, *tool_defs]
88
+ tool_defs = [
89
+ ASK_USER_TOOL,
90
+ OPEN_ATTACHMENT_TOOL,
91
+ RECALL_MEMORY_TOOL,
92
+ INSPECT_VARS_TOOL,
93
+ REMEMBER_TOOL,
94
+ REMEMBER_NOTE_TOOL,
95
+ COMPACT_MEMORY_TOOL,
96
+ DELEGATE_AGENT_TOOL,
97
+ *tool_defs,
98
+ ]
72
99
  logic = CodeActLogic(
73
100
  tools=tool_defs,
74
101
  max_history_messages=self._max_history_messages,
@@ -77,27 +104,99 @@ class CodeActAgent(BaseAgent):
77
104
  self.logic = logic
78
105
  return create_codeact_workflow(logic=logic, on_step=self.on_step)
79
106
 
80
- def start(self, task: str) -> str:
107
+ def start(
108
+ self,
109
+ task: str,
110
+ *,
111
+ plan_mode: Optional[bool] = None,
112
+ review_mode: Optional[bool] = None,
113
+ review_max_rounds: Optional[int] = None,
114
+ allowed_tools: Optional[List[str]] = None,
115
+ temperature: Optional[float] = None,
116
+ seed: Optional[int] = None,
117
+ attachments: Optional[List[Any]] = None,
118
+ ) -> str:
81
119
  task = str(task or "").strip()
82
120
  if not task:
83
121
  raise ValueError("task must be a non-empty string")
84
122
 
123
+ eff_plan_mode = self._plan_mode if plan_mode is None else bool(plan_mode)
124
+ eff_review_mode = self._review_mode if review_mode is None else bool(review_mode)
125
+ eff_review_max_rounds = self._review_max_rounds if review_max_rounds is None else int(review_max_rounds)
126
+ if eff_review_max_rounds < 0:
127
+ eff_review_max_rounds = 0
128
+
129
+ # Base limits come from the Runtime config so model capabilities (max context)
130
+ # are respected by default, unless explicitly overridden by the agent/session.
131
+ try:
132
+ base_limits = dict(self.runtime.config.to_limits_dict())
133
+ except Exception:
134
+ base_limits = {}
135
+ limits: Dict[str, Any] = dict(base_limits)
136
+ limits.setdefault("warn_iterations_pct", 80)
137
+ limits.setdefault("warn_tokens_pct", 80)
138
+ limits["max_iterations"] = int(self._max_iterations)
139
+ limits["current_iteration"] = 0
140
+ limits["max_history_messages"] = int(self._max_history_messages)
141
+ # Message-size guards for LLM-visible context (character-level).
142
+ # Disabled by default (-1): enable by setting a positive character budget.
143
+ limits.setdefault("max_message_chars", -1)
144
+ limits.setdefault("max_tool_message_chars", -1)
145
+ limits["estimated_tokens_used"] = 0
146
+ try:
147
+ max_tokens_override = int(self._max_tokens) if self._max_tokens is not None else None
148
+ except Exception:
149
+ max_tokens_override = None
150
+ if isinstance(max_tokens_override, int) and max_tokens_override > 0:
151
+ limits["max_tokens"] = max_tokens_override
152
+ if not isinstance(limits.get("max_tokens"), int) or int(limits.get("max_tokens") or 0) <= 0:
153
+ limits["max_tokens"] = 32768
154
+
85
155
  vars: Dict[str, Any] = {
86
156
  "context": {"task": task, "messages": _copy_messages(self.session_messages)},
87
157
  "scratchpad": {"iteration": 0, "max_iterations": int(self._max_iterations)},
88
- "_runtime": {"inbox": []},
158
+ "_runtime": {
159
+ "inbox": [],
160
+ "plan_mode": eff_plan_mode,
161
+ "review_mode": eff_review_mode,
162
+ "review_max_rounds": eff_review_max_rounds,
163
+ },
89
164
  "_temp": {},
90
165
  # 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
- },
166
+ "_limits": limits,
100
167
  }
168
+ if temperature is not None:
169
+ try:
170
+ vars["_runtime"]["temperature"] = float(temperature)
171
+ except Exception:
172
+ pass
173
+ if seed is not None:
174
+ try:
175
+ vars["_runtime"]["seed"] = int(seed)
176
+ except Exception:
177
+ pass
178
+ if isinstance(allowed_tools, list):
179
+ normalized = [str(t).strip() for t in allowed_tools if isinstance(t, str) and t.strip()]
180
+ vars["_runtime"]["allowed_tools"] = normalized
181
+ if attachments:
182
+ items: list[Any]
183
+ if isinstance(attachments, tuple):
184
+ items = list(attachments)
185
+ else:
186
+ items = attachments if isinstance(attachments, list) else []
187
+ normalized: list[Any] = []
188
+ for item in items:
189
+ if isinstance(item, str) and item.strip():
190
+ normalized.append(item.strip())
191
+ continue
192
+ if isinstance(item, dict):
193
+ aid = item.get("$artifact")
194
+ if not (isinstance(aid, str) and aid.strip()):
195
+ aid = item.get("artifact_id")
196
+ if isinstance(aid, str) and aid.strip():
197
+ normalized.append(dict(item))
198
+ if normalized:
199
+ vars["context"]["attachments"] = normalized
101
200
 
102
201
  run_id = self.runtime.start(
103
202
  workflow=self.workflow,
@@ -142,7 +241,10 @@ class CodeActAgent(BaseAgent):
142
241
  def step(self) -> RunState:
143
242
  if not self._current_run_id:
144
243
  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)
244
+ state = self.runtime.tick(workflow=self.workflow, run_id=self._current_run_id, max_steps=1)
245
+ if state.status in (RunStatus.COMPLETED, RunStatus.FAILED, RunStatus.CANCELLED):
246
+ self._sync_session_caches_from_state(state)
247
+ return state
146
248
 
147
249
 
148
250
  def create_codeact_agent(
@@ -153,7 +255,10 @@ def create_codeact_agent(
153
255
  on_step: Optional[Callable[[str, Dict[str, Any]], None]] = None,
154
256
  max_iterations: int = 25,
155
257
  max_history_messages: int = -1,
156
- max_tokens: Optional[int] = 32768,
258
+ max_tokens: Optional[int] = None,
259
+ plan_mode: bool = False,
260
+ review_mode: bool = True,
261
+ review_max_rounds: int = 3,
157
262
  llm_kwargs: Optional[Dict[str, Any]] = None,
158
263
  run_store: Optional[Any] = None,
159
264
  ledger_store: Optional[Any] = None,
@@ -185,10 +290,12 @@ def create_codeact_agent(
185
290
  max_iterations=max_iterations,
186
291
  max_history_messages=max_history_messages,
187
292
  max_tokens=max_tokens,
293
+ plan_mode=plan_mode,
294
+ review_mode=review_mode,
295
+ review_max_rounds=review_max_rounds,
188
296
  actor_id=actor_id,
189
297
  session_id=session_id,
190
298
  )
191
299
 
192
300
 
193
301
  __all__ = ["CodeActAgent", "create_codeact_workflow", "create_codeact_agent"]
194
-
@@ -0,0 +1,280 @@
1
+ """MemAct agent implementation (memory-enhanced).
2
+
3
+ MemAct is the only agent that uses `abstractruntime.memory.active_memory`.
4
+ ReAct and CodeAct remain conventional SOTA agents.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from typing import Any, Callable, Dict, List, Optional
11
+
12
+ from abstractcore.tools import ToolDefinition
13
+ from abstractruntime import RunState, RunStatus, Runtime, WorkflowSpec
14
+
15
+ from .base import BaseAgent
16
+ from ..adapters.memact_runtime import create_memact_workflow
17
+ from ..logic.builtins import (
18
+ ASK_USER_TOOL,
19
+ COMPACT_MEMORY_TOOL,
20
+ DELEGATE_AGENT_TOOL,
21
+ INSPECT_VARS_TOOL,
22
+ OPEN_ATTACHMENT_TOOL,
23
+ RECALL_MEMORY_TOOL,
24
+ REMEMBER_TOOL,
25
+ REMEMBER_NOTE_TOOL,
26
+ )
27
+ from ..logic.memact import MemActLogic
28
+
29
+
30
+ def _tool_definitions_from_callables(tools: List[Callable[..., Any]]) -> List[ToolDefinition]:
31
+ tool_defs: List[ToolDefinition] = []
32
+ for t in tools:
33
+ tool_def = getattr(t, "_tool_definition", None)
34
+ if tool_def is None:
35
+ tool_def = ToolDefinition.from_function(t)
36
+ tool_defs.append(tool_def)
37
+ return tool_defs
38
+
39
+
40
+ def _copy_messages(messages: Any) -> List[Dict[str, Any]]:
41
+ if not isinstance(messages, list):
42
+ return []
43
+ out: List[Dict[str, Any]] = []
44
+ for m in messages:
45
+ if isinstance(m, dict):
46
+ out.append(dict(m))
47
+ return out
48
+
49
+
50
+ def _deepcopy_json(value: Any) -> Any:
51
+ try:
52
+ return json.loads(json.dumps(value))
53
+ except Exception:
54
+ return value
55
+
56
+
57
+ class MemActAgent(BaseAgent):
58
+ """Memory-enhanced agent with runtime-owned Active Memory blocks."""
59
+
60
+ def __init__(
61
+ self,
62
+ *,
63
+ runtime: Runtime,
64
+ tools: Optional[List[Callable[..., Any]]] = None,
65
+ on_step: Optional[Callable[[str, Dict[str, Any]], None]] = None,
66
+ max_iterations: int = 25,
67
+ max_history_messages: int = -1,
68
+ max_tokens: Optional[int] = None,
69
+ plan_mode: bool = False,
70
+ review_mode: bool = False,
71
+ review_max_rounds: int = 1,
72
+ actor_id: Optional[str] = None,
73
+ session_id: Optional[str] = None,
74
+ ):
75
+ self._max_iterations = int(max_iterations)
76
+ if self._max_iterations < 1:
77
+ self._max_iterations = 1
78
+ self._max_history_messages = int(max_history_messages)
79
+ if self._max_history_messages != -1 and self._max_history_messages < 1:
80
+ self._max_history_messages = 1
81
+ self._max_tokens = max_tokens
82
+ self._plan_mode = bool(plan_mode)
83
+ self._review_mode = bool(review_mode)
84
+ self._review_max_rounds = int(review_max_rounds)
85
+ if self._review_max_rounds < 0:
86
+ self._review_max_rounds = 0
87
+
88
+ self.logic: Optional[MemActLogic] = None
89
+ self.session_active_memory: Optional[Dict[str, Any]] = None
90
+ super().__init__(
91
+ runtime=runtime,
92
+ tools=tools,
93
+ on_step=on_step,
94
+ actor_id=actor_id,
95
+ session_id=session_id,
96
+ )
97
+
98
+ def _create_workflow(self) -> WorkflowSpec:
99
+ tool_defs = _tool_definitions_from_callables(self.tools)
100
+ tool_defs = [
101
+ ASK_USER_TOOL,
102
+ OPEN_ATTACHMENT_TOOL,
103
+ RECALL_MEMORY_TOOL,
104
+ INSPECT_VARS_TOOL,
105
+ REMEMBER_TOOL,
106
+ REMEMBER_NOTE_TOOL,
107
+ COMPACT_MEMORY_TOOL,
108
+ DELEGATE_AGENT_TOOL,
109
+ *tool_defs,
110
+ ]
111
+ logic = MemActLogic(
112
+ tools=tool_defs,
113
+ max_history_messages=self._max_history_messages,
114
+ max_tokens=self._max_tokens,
115
+ )
116
+ self.logic = logic
117
+ return create_memact_workflow(logic=logic, on_step=self.on_step)
118
+
119
+ def _sync_session_caches_from_state(self, state: Optional[RunState]) -> None:
120
+ super()._sync_session_caches_from_state(state)
121
+ if state is None or not hasattr(state, "vars") or not isinstance(state.vars, dict):
122
+ return
123
+ runtime_ns = state.vars.get("_runtime")
124
+ if not isinstance(runtime_ns, dict):
125
+ return
126
+ mem = runtime_ns.get("active_memory")
127
+ if isinstance(mem, dict):
128
+ self.session_active_memory = _deepcopy_json(mem)
129
+
130
+ def start(
131
+ self,
132
+ task: str,
133
+ *,
134
+ plan_mode: Optional[bool] = None,
135
+ review_mode: Optional[bool] = None,
136
+ review_max_rounds: Optional[int] = None,
137
+ allowed_tools: Optional[List[str]] = None,
138
+ temperature: Optional[float] = None,
139
+ seed: Optional[int] = None,
140
+ attachments: Optional[List[Any]] = None,
141
+ ) -> str:
142
+ task = str(task or "").strip()
143
+ if not task:
144
+ raise ValueError("task must be a non-empty string")
145
+
146
+ try:
147
+ base_limits = dict(self.runtime.config.to_limits_dict())
148
+ except Exception:
149
+ base_limits = {}
150
+ limits: Dict[str, Any] = dict(base_limits)
151
+ limits.setdefault("warn_iterations_pct", 80)
152
+ limits.setdefault("warn_tokens_pct", 80)
153
+ limits["max_iterations"] = int(self._max_iterations)
154
+ limits["current_iteration"] = 0
155
+ limits["max_history_messages"] = int(self._max_history_messages)
156
+ limits["estimated_tokens_used"] = 0
157
+ try:
158
+ max_tokens_override = int(self._max_tokens) if self._max_tokens is not None else None
159
+ except Exception:
160
+ max_tokens_override = None
161
+ if isinstance(max_tokens_override, int) and max_tokens_override > 0:
162
+ limits["max_tokens"] = max_tokens_override
163
+ if not isinstance(limits.get("max_tokens"), int) or int(limits.get("max_tokens") or 0) <= 0:
164
+ limits["max_tokens"] = 32768
165
+
166
+ eff_plan_mode = self._plan_mode if plan_mode is None else bool(plan_mode)
167
+ eff_review_mode = self._review_mode if review_mode is None else bool(review_mode)
168
+ eff_review_max_rounds = self._review_max_rounds if review_max_rounds is None else int(review_max_rounds)
169
+ if eff_review_max_rounds < 0:
170
+ eff_review_max_rounds = 0
171
+
172
+ runtime_ns: Dict[str, Any] = {
173
+ "inbox": [],
174
+ "plan_mode": eff_plan_mode,
175
+ "review_mode": eff_review_mode,
176
+ "review_max_rounds": eff_review_max_rounds,
177
+ }
178
+ if temperature is not None:
179
+ try:
180
+ runtime_ns["temperature"] = float(temperature)
181
+ except Exception:
182
+ pass
183
+ if seed is not None:
184
+ try:
185
+ runtime_ns["seed"] = int(seed)
186
+ except Exception:
187
+ pass
188
+ if isinstance(self.session_active_memory, dict):
189
+ runtime_ns["active_memory"] = _deepcopy_json(self.session_active_memory)
190
+ if isinstance(allowed_tools, list):
191
+ normalized = [str(t).strip() for t in allowed_tools if isinstance(t, str) and t.strip()]
192
+ runtime_ns["allowed_tools"] = normalized
193
+
194
+ vars: Dict[str, Any] = {
195
+ "context": {"task": task, "messages": _copy_messages(self.session_messages)},
196
+ "scratchpad": {"iteration": 0, "max_iterations": int(self._max_iterations)},
197
+ "_runtime": runtime_ns,
198
+ "_temp": {},
199
+ "_limits": limits,
200
+ }
201
+ if attachments:
202
+ items: list[Any]
203
+ if isinstance(attachments, tuple):
204
+ items = list(attachments)
205
+ else:
206
+ items = attachments if isinstance(attachments, list) else []
207
+ normalized: list[Any] = []
208
+ for item in items:
209
+ if isinstance(item, str) and item.strip():
210
+ normalized.append(item.strip())
211
+ continue
212
+ if isinstance(item, dict):
213
+ aid = item.get("$artifact")
214
+ if not (isinstance(aid, str) and aid.strip()):
215
+ aid = item.get("artifact_id")
216
+ if isinstance(aid, str) and aid.strip():
217
+ normalized.append(dict(item))
218
+ if normalized:
219
+ vars["context"]["attachments"] = normalized
220
+
221
+ run_id = self.runtime.start(
222
+ workflow=self.workflow,
223
+ vars=vars,
224
+ actor_id=self._ensure_actor_id(),
225
+ session_id=self._ensure_session_id(),
226
+ )
227
+ self._current_run_id = run_id
228
+ return run_id
229
+
230
+ def step(self) -> RunState:
231
+ if not self._current_run_id:
232
+ raise RuntimeError("No active run. Call start() first.")
233
+ state = self.runtime.tick(workflow=self.workflow, run_id=self._current_run_id, max_steps=1)
234
+ if state.status in (RunStatus.COMPLETED, RunStatus.FAILED, RunStatus.CANCELLED):
235
+ self._sync_session_caches_from_state(state)
236
+ return state
237
+
238
+
239
+ def create_memact_agent(
240
+ *,
241
+ provider: str = "ollama",
242
+ model: str = "qwen3:1.7b-q4_K_M",
243
+ tools: Optional[List[Callable[..., Any]]] = None,
244
+ on_step: Optional[Callable[[str, Dict[str, Any]], None]] = None,
245
+ max_iterations: int = 25,
246
+ max_history_messages: int = -1,
247
+ max_tokens: Optional[int] = None,
248
+ llm_kwargs: Optional[Dict[str, Any]] = None,
249
+ run_store: Optional[Any] = None,
250
+ ledger_store: Optional[Any] = None,
251
+ actor_id: Optional[str] = None,
252
+ session_id: Optional[str] = None,
253
+ ) -> MemActAgent:
254
+ """Factory: create a MemActAgent with a local AbstractCore-backed runtime."""
255
+
256
+ from abstractruntime.integrations.abstractcore import MappingToolExecutor, create_local_runtime
257
+
258
+ if tools is None:
259
+ from ..tools import ALL_TOOLS
260
+
261
+ tools = list(ALL_TOOLS)
262
+
263
+ runtime = create_local_runtime(
264
+ provider=provider,
265
+ model=model,
266
+ llm_kwargs=llm_kwargs,
267
+ tool_executor=MappingToolExecutor.from_tools(list(tools)),
268
+ run_store=run_store,
269
+ ledger_store=ledger_store,
270
+ )
271
+ return MemActAgent(
272
+ runtime=runtime,
273
+ tools=tools,
274
+ on_step=on_step,
275
+ max_iterations=max_iterations,
276
+ max_history_messages=max_history_messages,
277
+ max_tokens=max_tokens,
278
+ actor_id=actor_id,
279
+ session_id=session_id,
280
+ )