abstractagent 0.2.0__py3-none-any.whl → 0.3.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.
@@ -15,11 +15,18 @@ from __future__ import annotations
15
15
  from typing import Any, Callable, Dict, List, Optional
16
16
 
17
17
  from abstractcore.tools import ToolDefinition
18
- from abstractruntime import RunState, Runtime, WorkflowSpec
18
+ from abstractruntime import RunState, RunStatus, Runtime, WorkflowSpec
19
19
 
20
20
  from .base import BaseAgent
21
21
  from ..adapters.react_runtime import create_react_workflow
22
- from ..logic.builtins import ASK_USER_TOOL
22
+ from ..logic.builtins import (
23
+ ASK_USER_TOOL,
24
+ COMPACT_MEMORY_TOOL,
25
+ INSPECT_VARS_TOOL,
26
+ RECALL_MEMORY_TOOL,
27
+ REMEMBER_TOOL,
28
+ REMEMBER_NOTE_TOOL,
29
+ )
23
30
  from ..logic.react import ReActLogic
24
31
 
25
32
 
@@ -54,7 +61,10 @@ class ReactAgent(BaseAgent):
54
61
  on_step: Optional[Callable[[str, Dict[str, Any]], None]] = None,
55
62
  max_iterations: int = 25,
56
63
  max_history_messages: int = -1,
57
- max_tokens: Optional[int] = 32768,
64
+ max_tokens: Optional[int] = None,
65
+ plan_mode: bool = False,
66
+ review_mode: bool = True,
67
+ review_max_rounds: int = 3,
58
68
  actor_id: Optional[str] = None,
59
69
  session_id: Optional[str] = None,
60
70
  ):
@@ -66,6 +76,11 @@ class ReactAgent(BaseAgent):
66
76
  if self._max_history_messages != -1 and self._max_history_messages < 1:
67
77
  self._max_history_messages = 1
68
78
  self._max_tokens = max_tokens
79
+ self._plan_mode = bool(plan_mode)
80
+ self._review_mode = bool(review_mode)
81
+ self._review_max_rounds = int(review_max_rounds)
82
+ if self._review_max_rounds < 0:
83
+ self._review_max_rounds = 0
69
84
 
70
85
  self.logic: Optional[ReActLogic] = None
71
86
  super().__init__(
@@ -79,7 +94,15 @@ class ReactAgent(BaseAgent):
79
94
  def _create_workflow(self) -> WorkflowSpec:
80
95
  tool_defs = _tool_definitions_from_callables(self.tools)
81
96
  # Built-in ask_user is a schema-only tool (handled via ASK_USER effect in the adapter).
82
- tool_defs = [ASK_USER_TOOL, *tool_defs]
97
+ tool_defs = [
98
+ ASK_USER_TOOL,
99
+ RECALL_MEMORY_TOOL,
100
+ INSPECT_VARS_TOOL,
101
+ REMEMBER_TOOL,
102
+ REMEMBER_NOTE_TOOL,
103
+ COMPACT_MEMORY_TOOL,
104
+ *tool_defs,
105
+ ]
83
106
 
84
107
  logic = ReActLogic(
85
108
  tools=tool_defs,
@@ -89,27 +112,69 @@ class ReactAgent(BaseAgent):
89
112
  self.logic = logic
90
113
  return create_react_workflow(logic=logic, on_step=self.on_step)
91
114
 
92
- def start(self, task: str) -> str:
115
+ def start(
116
+ self,
117
+ task: str,
118
+ *,
119
+ plan_mode: Optional[bool] = None,
120
+ review_mode: Optional[bool] = None,
121
+ review_max_rounds: Optional[int] = None,
122
+ allowed_tools: Optional[List[str]] = None,
123
+ ) -> str:
93
124
  task = str(task or "").strip()
94
125
  if not task:
95
126
  raise ValueError("task must be a non-empty string")
96
127
 
128
+ eff_plan_mode = self._plan_mode if plan_mode is None else bool(plan_mode)
129
+ eff_review_mode = self._review_mode if review_mode is None else bool(review_mode)
130
+ eff_review_max_rounds = self._review_max_rounds if review_max_rounds is None else int(review_max_rounds)
131
+ if eff_review_max_rounds < 0:
132
+ eff_review_max_rounds = 0
133
+
134
+ # Base limits come from the Runtime config so model capabilities (max context)
135
+ # are respected by default, unless explicitly overridden by the agent/session.
136
+ try:
137
+ base_limits = dict(self.runtime.config.to_limits_dict())
138
+ except Exception:
139
+ base_limits = {}
140
+ limits: Dict[str, Any] = dict(base_limits)
141
+ limits.setdefault("warn_iterations_pct", 80)
142
+ limits.setdefault("warn_tokens_pct", 80)
143
+ limits["max_iterations"] = int(self._max_iterations)
144
+ limits["current_iteration"] = 0
145
+ limits["max_history_messages"] = int(self._max_history_messages)
146
+ # Message-size guards for LLM-visible context (character-level).
147
+ # These are applied when building the provider payload; the durable run history
148
+ # still preserves full message text.
149
+ # Disabled by default (-1): enable by setting a positive character budget.
150
+ limits.setdefault("max_message_chars", -1)
151
+ limits.setdefault("max_tool_message_chars", -1)
152
+ limits["estimated_tokens_used"] = 0
153
+ try:
154
+ max_tokens_override = int(self._max_tokens) if self._max_tokens is not None else None
155
+ except Exception:
156
+ max_tokens_override = None
157
+ if isinstance(max_tokens_override, int) and max_tokens_override > 0:
158
+ limits["max_tokens"] = max_tokens_override
159
+ if not isinstance(limits.get("max_tokens"), int) or int(limits.get("max_tokens") or 0) <= 0:
160
+ limits["max_tokens"] = 32768
161
+
97
162
  vars: Dict[str, Any] = {
98
163
  "context": {"task": task, "messages": _copy_messages(self.session_messages)},
99
164
  "scratchpad": {"iteration": 0, "max_iterations": int(self._max_iterations)},
100
- "_runtime": {"inbox": []},
165
+ "_runtime": {
166
+ "inbox": [],
167
+ "plan_mode": eff_plan_mode,
168
+ "review_mode": eff_review_mode,
169
+ "review_max_rounds": eff_review_max_rounds,
170
+ },
101
171
  "_temp": {},
102
172
  # Canonical _limits namespace for runtime awareness
103
- "_limits": {
104
- "max_iterations": int(self._max_iterations),
105
- "current_iteration": 0,
106
- "max_tokens": self._max_tokens,
107
- "max_history_messages": int(self._max_history_messages),
108
- "estimated_tokens_used": 0,
109
- "warn_iterations_pct": 80,
110
- "warn_tokens_pct": 80,
111
- },
173
+ "_limits": limits,
112
174
  }
175
+ if isinstance(allowed_tools, list):
176
+ normalized = [str(t).strip() for t in allowed_tools if isinstance(t, str) and t.strip()]
177
+ vars["_runtime"]["allowed_tools"] = normalized
113
178
 
114
179
  run_id = self.runtime.start(
115
180
  workflow=self.workflow,
@@ -154,7 +219,10 @@ class ReactAgent(BaseAgent):
154
219
  def step(self) -> RunState:
155
220
  if not self._current_run_id:
156
221
  raise RuntimeError("No active run. Call start() first.")
157
- return self.runtime.tick(workflow=self.workflow, run_id=self._current_run_id, max_steps=1)
222
+ state = self.runtime.tick(workflow=self.workflow, run_id=self._current_run_id, max_steps=1)
223
+ if state.status in (RunStatus.COMPLETED, RunStatus.FAILED, RunStatus.CANCELLED):
224
+ self._sync_session_caches_from_state(state)
225
+ return state
158
226
 
159
227
 
160
228
  def create_react_agent(
@@ -165,7 +233,10 @@ def create_react_agent(
165
233
  on_step: Optional[Callable[[str, Dict[str, Any]], None]] = None,
166
234
  max_iterations: int = 25,
167
235
  max_history_messages: int = -1,
168
- max_tokens: Optional[int] = 32768,
236
+ max_tokens: Optional[int] = None,
237
+ plan_mode: bool = False,
238
+ review_mode: bool = True,
239
+ review_max_rounds: int = 3,
169
240
  llm_kwargs: Optional[Dict[str, Any]] = None,
170
241
  run_store: Optional[Any] = None,
171
242
  ledger_store: Optional[Any] = None,
@@ -197,6 +268,9 @@ def create_react_agent(
197
268
  max_iterations=max_iterations,
198
269
  max_history_messages=max_history_messages,
199
270
  max_tokens=max_tokens,
271
+ plan_mode=plan_mode,
272
+ review_mode=review_mode,
273
+ review_max_rounds=review_max_rounds,
200
274
  actor_id=actor_id,
201
275
  session_id=session_id,
202
276
  )
@@ -207,4 +281,3 @@ __all__ = [
207
281
  "create_react_workflow",
208
282
  "create_react_agent",
209
283
  ]
210
-
@@ -6,6 +6,7 @@ workflow wiring lives under `abstractagent.adapters`.
6
6
 
7
7
  from .builtins import ASK_USER_TOOL
8
8
  from .codeact import CodeActLogic
9
+ from .memact import MemActLogic
9
10
  from .react import ReActLogic
10
11
  from .types import AskUserAction, FinalAnswer, LLMRequest
11
12
 
@@ -16,4 +17,5 @@ __all__ = [
16
17
  "FinalAnswer",
17
18
  "ReActLogic",
18
19
  "CodeActLogic",
20
+ "MemActLogic",
19
21
  ]
@@ -9,10 +9,7 @@ from abstractcore.tools import ToolDefinition
9
9
 
10
10
  ASK_USER_TOOL = ToolDefinition(
11
11
  name="ask_user",
12
- description=(
13
- "Ask the user a question when you need clarification or input. "
14
- "Use this when the task is ambiguous or you need the user to make a choice."
15
- ),
12
+ description="Ask the user a question.",
16
13
  parameters={
17
14
  "question": {
18
15
  "type": "string",
@@ -22,8 +19,218 @@ ASK_USER_TOOL = ToolDefinition(
22
19
  "type": "array",
23
20
  "items": {"type": "string"},
24
21
  "description": "Optional list of choices for the user to pick from",
22
+ "default": None,
23
+ },
24
+ },
25
+ when_to_use="Use when the task is ambiguous or you need user input to proceed.",
26
+ )
27
+
28
+ RECALL_MEMORY_TOOL = ToolDefinition(
29
+ name="recall_memory",
30
+ description="Recall archived memory spans with provenance (by span_id/query/tags/time range).",
31
+ parameters={
32
+ "span_id": {
33
+ "type": "string",
34
+ "description": (
35
+ "Optional span identifier (artifact id) or 1-based index into archived spans. "
36
+ "If a summary includes span_id=..., use that exact value."
37
+ ),
38
+ "default": None,
39
+ },
40
+ "query": {
41
+ "type": "string",
42
+ "description": "Optional keyword query (topic/person/etc). Performs metadata-first search with bounded deep scan over archived messages.",
43
+ "default": None,
44
+ },
45
+ "since": {
46
+ "type": "string",
47
+ "description": "Optional ISO8601 start timestamp for time-range filtering.",
48
+ "default": None,
49
+ },
50
+ "until": {
51
+ "type": "string",
52
+ "description": "Optional ISO8601 end timestamp for time-range filtering.",
53
+ "default": None,
54
+ },
55
+ "tags": {
56
+ "type": "object",
57
+ "description": (
58
+ "Optional metadata tag filters.\n"
59
+ "- Values may be a string or a list of strings.\n"
60
+ "- Example: {\"topic\":\"api\",\"person\":[\"alice\",\"bob\"]}\n"
61
+ "Use tags_mode to control AND/OR across tag keys."
62
+ ),
63
+ "default": None,
64
+ },
65
+ "tags_mode": {
66
+ "type": "string",
67
+ "description": (
68
+ "How to combine tag keys: all (AND across keys) | any (OR across keys). "
69
+ "Within a key, list values are treated as OR."
70
+ ),
71
+ "default": "all",
72
+ },
73
+ "usernames": {
74
+ "type": "array",
75
+ "items": {"type": "string"},
76
+ "description": (
77
+ "Optional author filter (actor ids / usernames). Matches spans created_by case-insensitively. "
78
+ "Semantics: OR (any listed author)."
79
+ ),
80
+ "default": None,
81
+ },
82
+ "locations": {
83
+ "type": "array",
84
+ "items": {"type": "string"},
85
+ "description": (
86
+ "Optional location filter. Matches spans by explicit location metadata (or tags.location). "
87
+ "Semantics: OR (any listed location)."
88
+ ),
89
+ "default": None,
90
+ },
91
+ "limit_spans": {
92
+ "type": "integer",
93
+ "description": "Maximum number of spans to return (default 5).",
94
+ "default": 5,
95
+ },
96
+ "connected": {
97
+ "type": "boolean",
98
+ "description": "If true, also include connected spans (time neighbors and shared-tag neighbors).",
99
+ "default": False,
100
+ },
101
+ "neighbor_hops": {
102
+ "type": "integer",
103
+ "description": "When connected=true, include up to this many neighbor spans on each side (default 1).",
104
+ "default": 1,
105
+ },
106
+ "max_messages": {
107
+ "type": "integer",
108
+ "description": "Maximum total messages to render in the recall output across all spans (-1 = no truncation).",
109
+ "default": -1,
110
+ },
111
+ "scope": {
112
+ "type": "string",
113
+ "description": "Memory scope to query: run | session | global | all (default run).",
114
+ "default": "run",
115
+ },
116
+ },
117
+ when_to_use="Use after compaction or when you need exact details from earlier context.",
118
+ )
119
+
120
+ INSPECT_VARS_TOOL = ToolDefinition(
121
+ name="inspect_vars",
122
+ description="Inspect durable run-state variables by path (e.g., scratchpad/runtime vars).",
123
+ parameters={
124
+ "path": {
125
+ "type": "string",
126
+ "description": (
127
+ "Path to inspect (default 'scratchpad'). Supports dot paths like 'scratchpad.foo[0]' "
128
+ "or JSON pointer paths like '/scratchpad/foo/0'."
129
+ ),
130
+ "default": "scratchpad",
131
+ },
132
+ "keys_only": {
133
+ "type": "boolean",
134
+ "description": "If true, return keys/length instead of the full value (useful to navigate large objects).",
135
+ "default": False,
136
+ },
137
+ "target_run_id": {
138
+ "type": "string",
139
+ "description": "Optional run id to inspect (defaults to the current run).",
140
+ "default": None,
141
+ },
142
+ },
143
+ when_to_use=(
144
+ "Use to debug or inspect scratchpad/runtime vars (prefer keys_only=true first)."
145
+ ),
146
+ )
147
+
148
+ REMEMBER_TOOL = ToolDefinition(
149
+ name="remember",
150
+ description="Tag an archived memory span for later recall.",
151
+ parameters={
152
+ "span_id": {
153
+ "type": "string",
154
+ "description": (
155
+ "Span identifier (artifact id) or 1-based index into archived spans. "
156
+ "If a summary includes span_id=..., use that exact value."
157
+ ),
158
+ },
159
+ "tags": {
160
+ "type": "object",
161
+ "description": (
162
+ "Tags to set on the span (JSON-safe dict[str,str]), e.g. {\"topic\":\"api\",\"person\":\"alice\"}. "
163
+ "At least one tag is required."
164
+ ),
165
+ },
166
+ "merge": {
167
+ "type": "boolean",
168
+ "description": "If true (default), merges tags into existing tags. If false, replaces existing tags.",
169
+ "default": True,
170
+ },
171
+ },
172
+ when_to_use=(
173
+ "Use when you want to label a recalled/compacted span with durable tags."
174
+ ),
175
+ )
176
+
177
+ REMEMBER_NOTE_TOOL = ToolDefinition(
178
+ name="remember_note",
179
+ description="Store a durable memory note (decision/fact) with optional tags and sources.",
180
+ parameters={
181
+ "note": {
182
+ "type": "string",
183
+ "description": "The note to remember (required). Keep it short and specific.",
184
+ },
185
+ "tags": {
186
+ "type": "object",
187
+ "description": "Optional tags (dict[str,str]) to help recall later, e.g. {\"topic\":\"api\",\"person\":\"alice\"}.",
188
+ "default": None,
189
+ },
190
+ "sources": {
191
+ "type": "object",
192
+ "description": (
193
+ "Optional provenance sources for this note. Use span_ids/message_ids when available.\n"
194
+ "Example: {\"span_ids\":[\"span_...\"], \"message_ids\":[\"msg_...\"]}"
195
+ ),
196
+ "default": None,
197
+ },
198
+ "location": {
199
+ "type": "string",
200
+ "description": "Optional location for this memory note (user perspective).",
201
+ "default": None,
202
+ },
203
+ "scope": {
204
+ "type": "string",
205
+ "description": "Where to store this note: run | session | global (default run).",
206
+ "default": "run",
25
207
  },
26
208
  },
27
- when_to_use="When the task is ambiguous or you need user input to proceed",
209
+ when_to_use=(
210
+ "When you want to persist a key insight/decision/fact for later recall by time/topic/person, "
211
+ "especially before any compaction span exists."
212
+ ),
28
213
  )
29
214
 
215
+ COMPACT_MEMORY_TOOL = ToolDefinition(
216
+ name="compact_memory",
217
+ description="Compact older conversation context into an archived span and insert a summary handle.",
218
+ parameters={
219
+ "preserve_recent": {
220
+ "type": "integer",
221
+ "description": "Number of most recent non-system messages to keep verbatim (default 6).",
222
+ "default": 6,
223
+ },
224
+ "compression_mode": {
225
+ "type": "string",
226
+ "description": "Compression mode: light | standard | heavy (default standard).",
227
+ "default": "standard",
228
+ },
229
+ "focus": {
230
+ "type": "string",
231
+ "description": "Optional focus/topic to prioritize in the summary.",
232
+ "default": None,
233
+ },
234
+ },
235
+ when_to_use="Use when the active context is too large and you need to reduce it while keeping provenance.",
236
+ )
@@ -1,7 +1,11 @@
1
1
  """CodeAct logic (pure; no runtime imports).
2
2
 
3
- CodeAct is a ReAct-like loop where the main action is executing Python code
4
- instead of calling many specialized tools.
3
+ This module implements a conventional CodeAct loop:
4
+ - the model primarily acts by producing Python code (or calling execute_python)
5
+ - tool results are appended to chat history
6
+ - the model iterates until it can answer directly
7
+
8
+ CodeAct is intentionally *not* a memory-enhanced agent.
5
9
  """
6
10
 
7
11
  from __future__ import annotations
@@ -26,7 +30,6 @@ class CodeActLogic:
26
30
  ):
27
31
  self._tools = list(tools)
28
32
  self._max_history_messages = int(max_history_messages)
29
- # -1 means unlimited (send all messages), otherwise must be >= 1
30
33
  if self._max_history_messages != -1 and self._max_history_messages < 1:
31
34
  self._max_history_messages = 1
32
35
  self._max_tokens = max_tokens
@@ -35,6 +38,23 @@ class CodeActLogic:
35
38
  def tools(self) -> List[ToolDefinition]:
36
39
  return list(self._tools)
37
40
 
41
+ def add_tools(self, tools: List[ToolDefinition]) -> int:
42
+ if not isinstance(tools, list) or not tools:
43
+ return 0
44
+
45
+ existing = {str(t.name) for t in self._tools if getattr(t, "name", None)}
46
+ added = 0
47
+ for t in tools:
48
+ name = getattr(t, "name", None)
49
+ if not isinstance(name, str) or not name.strip():
50
+ continue
51
+ if name in existing:
52
+ continue
53
+ self._tools.append(t)
54
+ existing.add(name)
55
+ added += 1
56
+ return added
57
+
38
58
  def build_request(
39
59
  self,
40
60
  *,
@@ -45,58 +65,70 @@ class CodeActLogic:
45
65
  max_iterations: int = 20,
46
66
  vars: Optional[Dict[str, Any]] = None,
47
67
  ) -> LLMRequest:
48
- """Build an LLM request for the CodeAct agent.
49
-
50
- Args:
51
- task: The task to perform
52
- messages: Conversation history
53
- guidance: Optional guidance text to inject
54
- iteration: Current iteration number
55
- max_iterations: Maximum allowed iterations
56
- vars: Optional run.vars dict. If provided, limits are read from
57
- vars["_limits"] (canonical) with fallback to instance defaults.
58
- """
68
+ _ = messages # history is carried out-of-band via chat messages
69
+
59
70
  task = str(task or "")
60
71
  guidance = str(guidance or "").strip()
61
72
 
62
- # Get limits from vars if available, else use instance defaults
63
73
  limits = (vars or {}).get("_limits", {})
64
- max_history = int(limits.get("max_history_messages", self._max_history_messages) or self._max_history_messages)
65
- max_tokens = limits.get("max_tokens", self._max_tokens)
66
- if max_tokens is not None:
67
- max_tokens = int(max_tokens)
68
-
69
- # -1 means unlimited (use all messages)
70
- if max_history == -1:
71
- history = messages if messages else []
72
- else:
73
- history = messages[-max_history:] if messages else []
74
- history_text = "\n".join(
75
- [f"{m.get('role', 'unknown')}: {m.get('content', '')}" for m in history]
76
- )
77
-
78
- prompt = (
79
- "You are CodeAct: you can solve tasks by writing and executing Python code.\n"
80
- "Use the tool `execute_python` to run Python snippets. Prefer small, focused scripts.\n"
81
- "Print any intermediate results you need.\n"
82
- "When you are confident, provide the final answer without calling tools.\n\n"
74
+ max_output_tokens = limits.get("max_output_tokens", None)
75
+ if max_output_tokens is not None:
76
+ try:
77
+ max_output_tokens = int(max_output_tokens)
78
+ except Exception:
79
+ max_output_tokens = None
80
+
81
+ runtime_ns = (vars or {}).get("_runtime", {})
82
+ scratchpad = (vars or {}).get("scratchpad", {})
83
+ plan_mode = bool(runtime_ns.get("plan_mode")) if isinstance(runtime_ns, dict) else False
84
+ plan_text = scratchpad.get("plan") if isinstance(scratchpad, dict) else None
85
+ plan = str(plan_text).strip() if isinstance(plan_text, str) and plan_text.strip() else ""
86
+
87
+ prompt = task.strip()
88
+
89
+ output_budget_line = ""
90
+ if isinstance(max_output_tokens, int) and max_output_tokens > 0:
91
+ output_budget_line = f"- Output token limit for this response: {max_output_tokens}.\n"
92
+
93
+ system_prompt = (
83
94
  f"Iteration: {int(iteration)}/{int(max_iterations)}\n\n"
84
- f"Task: {task}\n\n"
85
- )
86
- if history_text:
87
- prompt += f"History:\n{history_text}\n\n"
95
+ "You are CodeAct: you solve tasks by writing and executing Python when needed.\n\n"
96
+ "Evidence & action (IMPORTANT):\n"
97
+ "- Be truthful: only claim actions supported by tool outputs.\n"
98
+ "- If the task requires code execution or file edits, do it now (call a tool or output a fenced ```python``` block).\n"
99
+ "- Do not “announce” actions without executing them.\n\n"
100
+ "Rules:\n"
101
+ "- Be truthful: only claim actions supported by tool outputs.\n"
102
+ "- Be autonomous: do not ask the user for confirmation to proceed; keep going until the task is done.\n"
103
+ "- If you need to run code, call `execute_python` (preferred) or output a fenced ```python code block.\n"
104
+ "- Never fabricate tool outputs.\n"
105
+ "- Only ask the user a question when required information is missing.\n"
106
+ f"{output_budget_line}"
107
+ ).strip()
88
108
 
89
109
  if guidance:
90
- prompt += f"[User guidance]: {guidance}\n\n"
91
-
92
- prompt += (
93
- "If you need to run code, either:\n"
94
- "- Call `execute_python` with the Python code, or\n"
95
- "- If tool calling is unavailable, include a fenced ```python code block.\n"
110
+ system_prompt = (system_prompt + "\n\nGuidance:\n" + guidance).strip()
111
+
112
+ if plan_mode and plan:
113
+ system_prompt = (system_prompt + "\n\nCurrent plan:\n" + plan).strip()
114
+
115
+ if plan_mode:
116
+ system_prompt = (
117
+ system_prompt
118
+ + "\n\nPlan mode:\n"
119
+ "- Maintain and update the plan as you work.\n"
120
+ "- If the plan changes, include a final section at the END of your message:\n"
121
+ " Plan Update:\n"
122
+ " <markdown checklist>\n"
123
+ ).strip()
124
+
125
+ return LLMRequest(
126
+ prompt=prompt,
127
+ system_prompt=system_prompt,
128
+ tools=self.tools,
129
+ max_tokens=max_output_tokens,
96
130
  )
97
131
 
98
- return LLMRequest(prompt=prompt, tools=self.tools, max_tokens=max_tokens)
99
-
100
132
  def parse_response(self, response: Any) -> Tuple[str, List[ToolCall]]:
101
133
  if not isinstance(response, dict):
102
134
  return "", []
@@ -104,6 +136,11 @@ class CodeActLogic:
104
136
  content = response.get("content")
105
137
  content = "" if content is None else str(content)
106
138
 
139
+ if not content.strip():
140
+ reasoning = response.get("reasoning")
141
+ if isinstance(reasoning, str) and reasoning.strip():
142
+ content = reasoning.strip()
143
+
107
144
  tool_calls_raw = response.get("tool_calls") or []
108
145
  tool_calls: List[ToolCall] = []
109
146
  if isinstance(tool_calls_raw, list):
@@ -118,15 +155,6 @@ class CodeActLogic:
118
155
  if isinstance(args, dict):
119
156
  tool_calls.append(ToolCall(name=name, arguments=dict(args), call_id=call_id))
120
157
 
121
- # FALLBACK: Parse from content if no native tool calls
122
- # Handles <|tool_call|>, <function_call>, ```tool_code, etc.
123
- if not tool_calls and content:
124
- from abstractcore.tools.parser import parse_tool_calls, detect_tool_calls
125
- if detect_tool_calls(content):
126
- # Pass model name for architecture-specific parsing
127
- model_name = response.get("model")
128
- tool_calls = parse_tool_calls(content, model_name=model_name)
129
-
130
158
  return content, tool_calls
131
159
 
132
160
  def extract_code(self, text: str) -> str | None:
@@ -138,29 +166,8 @@ class CodeActLogic:
138
166
  return code.strip() or None
139
167
 
140
168
  def format_observation(self, *, name: str, output: Any, success: bool) -> str:
141
- if name != "execute_python":
142
- out = "" if output is None else str(output)
143
- return f"[{name}]: {out}" if success else f"[{name}]: Error: {out}"
144
-
145
- if not isinstance(output, dict):
146
- out = "" if output is None else str(output)
147
- return f"[execute_python]: {out}" if success else f"[execute_python]: Error: {out}"
148
-
149
- stdout = str(output.get("stdout") or "")
150
- stderr = str(output.get("stderr") or "")
151
- exit_code = output.get("exit_code")
152
- error = output.get("error")
153
-
154
- parts: List[str] = []
155
- if error:
156
- parts.append(f"error={error}")
157
- if exit_code is not None:
158
- parts.append(f"exit_code={exit_code}")
159
- if stdout:
160
- parts.append("stdout:\n" + stdout)
161
- if stderr:
162
- parts.append("stderr:\n" + stderr)
163
-
164
- rendered = "\n".join(parts).strip() or "(no output)"
165
- return f"[execute_python]: {rendered}"
169
+ out = "" if output is None else str(output)
170
+ if success:
171
+ return f"[{name}]: {out}"
172
+ return f"[{name}]: Error: {out}"
166
173