agentx-dev 3.0.2__tar.gz → 3.0.5__tar.gz

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.
Files changed (55) hide show
  1. {agentx_dev-3.0.2/agentx_dev.egg-info → agentx_dev-3.0.5}/PKG-INFO +1 -1
  2. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/agentx_dev/Agents/Agent.py +139 -14
  3. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/agentx_dev/Runner/AgentRun.py +160 -40
  4. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/agentx_dev/Runner/AsyncAgentRun.py +84 -2
  5. {agentx_dev-3.0.2 → agentx_dev-3.0.5/agentx_dev.egg-info}/PKG-INFO +1 -1
  6. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/agentx_dev.egg-info/top_level.txt +0 -1
  7. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/pyproject.toml +1 -1
  8. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/AGENTX.md +0 -0
  9. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/LICENSE +0 -0
  10. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/MANIFEST.in +0 -0
  11. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/README.md +0 -0
  12. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/agentx_dev/Agents/__init__.py +0 -0
  13. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/agentx_dev/AsyncTools.py +0 -0
  14. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/agentx_dev/AutoSetup.py +0 -0
  15. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/agentx_dev/Cache.py +0 -0
  16. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/agentx_dev/ChatModel.py +0 -0
  17. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/agentx_dev/Config.py +0 -0
  18. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/agentx_dev/DefaultTools.py +0 -0
  19. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/agentx_dev/Loader.py +0 -0
  20. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/agentx_dev/MCP.py +0 -0
  21. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/agentx_dev/Memory.py +0 -0
  22. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/agentx_dev/Observability.py +0 -0
  23. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/agentx_dev/Planner.py +0 -0
  24. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/agentx_dev/Runner/__init__.py +0 -0
  25. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/agentx_dev/Runner/promptTemplate.yaml +0 -0
  26. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/agentx_dev/Session.py +0 -0
  27. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/agentx_dev/Streaming.py +0 -0
  28. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/agentx_dev/Supervisor.py +0 -0
  29. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/agentx_dev/Tools.py +0 -0
  30. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/agentx_dev/WebTools.py +0 -0
  31. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/agentx_dev/__init__.py +0 -0
  32. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/agentx_dev/resources/__init__.py +0 -0
  33. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/agentx_dev/resources/promptTemplate.yaml +0 -0
  34. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/agentx_dev.egg-info/SOURCES.txt +0 -0
  35. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/agentx_dev.egg-info/dependency_links.txt +0 -0
  36. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/agentx_dev.egg-info/requires.txt +0 -0
  37. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/examples/README.md +0 -0
  38. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/examples/async_example.py +0 -0
  39. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/examples/async_quickstart.py +0 -0
  40. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/examples/auto_features_example.py +0 -0
  41. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/examples/caching_example.py +0 -0
  42. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/examples/chatbot_example.py +0 -0
  43. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/examples/complete_example.py +0 -0
  44. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/examples/concurrent_example.py +0 -0
  45. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/examples/concurrent_tool_example.py +0 -0
  46. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/examples/file_agent_demo.py +0 -0
  47. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/examples/function_calling_demo.py +0 -0
  48. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/examples/mcp_demo.py +0 -0
  49. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/examples/observability_example.py +0 -0
  50. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/examples/orchestration_demo.py +0 -0
  51. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/examples/planner_example.py +0 -0
  52. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/examples/supervisor_codebase_analysis_demo.py +0 -0
  53. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/examples/supervisor_example.py +0 -0
  54. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/examples/sync_quickstart.py +0 -0
  55. {agentx_dev-3.0.2 → agentx_dev-3.0.5}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentx-dev
3
- Version: 3.0.2
3
+ Version: 3.0.5
4
4
  Summary: A lightweight LLM agent framework with multi-agent orchestration (Supervisor + dynamic specialist spawning), permission-gated default tools, MCP integration, and prompt-template presets.
5
5
  Author-email: Bruce-Arhin Shadrach <brucearhin098@gmail.com>
6
6
  License: MIT License
@@ -99,8 +99,28 @@ class StandardParser(_ParserMixin, BaseModel):
99
99
  - from_json: Creates an instance from a JSON string.
100
100
  - from_function_call: Creates an instance from a tool-call args dict.
101
101
  """
102
- action: str = Field("The action to take", alias='action')
103
- action_input: str | Dict | List = Field("The input to the action", alias='action_input')
102
+ action: str = Field(
103
+ default="",
104
+ description=(
105
+ "REQUIRED. Must be one of the registered tool names shown "
106
+ "in the system prompt, OR the exact literal string "
107
+ "'Final_Answer' (with underscore) when you are ready to "
108
+ "return the completed answer. Do NOT put natural-language "
109
+ "response text here — that goes in action_input."
110
+ ),
111
+ alias='action',
112
+ )
113
+ action_input: str | Dict | List = Field(
114
+ default="",
115
+ description=(
116
+ "REQUIRED. When action is a tool name, this is the JSON "
117
+ "object matching that tool's argument schema (or the "
118
+ "single string the tool expects). When action is "
119
+ "'Final_Answer', this is the actual answer TEXT you want "
120
+ "the user to see."
121
+ ),
122
+ alias='action_input',
123
+ )
104
124
 
105
125
  model_config = ConfigDict(populate_by_name=True)
106
126
 
@@ -150,8 +170,27 @@ class Instruction_Tuned_(_ParserMixin, BaseModel):
150
170
  - action: str, the action to take or final answer.
151
171
  - action_input: str, Dict, or List, the input to the action.
152
172
  """
153
- action: str = Field("The action to take OR Final Answer", alias="action")
154
- action_input: str | Dict | List = Field("The input to the action OR You should put what you want to return to use here and return to user immediately", alias="action_input")
173
+ action: str = Field(
174
+ default="",
175
+ description=(
176
+ "REQUIRED. Must be one of the registered tool names shown "
177
+ "in the system prompt, OR the exact literal string "
178
+ "'Final_Answer' (with underscore) when you are ready to "
179
+ "return the completed answer. Never put natural-language "
180
+ "response text here — that goes in action_input."
181
+ ),
182
+ alias='action',
183
+ )
184
+ action_input: str | Dict | List = Field(
185
+ default="",
186
+ description=(
187
+ "REQUIRED. When action is a tool name, this is the JSON "
188
+ "object matching that tool's schema (or the single string "
189
+ "it expects). When action is 'Final_Answer', this is the "
190
+ "actual answer TEXT you want the user to see."
191
+ ),
192
+ alias='action_input',
193
+ )
155
194
 
156
195
  model_config = ConfigDict(populate_by_name=True)
157
196
 
@@ -172,9 +211,36 @@ class React_(_ParserMixin, BaseModel):
172
211
  - action: str, the action to take or final answer.
173
212
  - action_input: str, Dict, or List, the input to the action.
174
213
  """
175
- Thought: str = Field('The Agent Thoughts', alias='Thought')
176
- action: str = Field("The action to take OR Final Answer", alias="action")
177
- action_input: str | Dict | List = Field("The input to the action OR You should put what you want to return to use here and return to user immediately", alias="action_input")
214
+ Thought: str = Field(
215
+ default="",
216
+ description=(
217
+ "REQUIRED. Brief reasoning for THIS turn: what the last "
218
+ "tool returned + what you'll do differently now. Keep it "
219
+ "concrete, not generic."
220
+ ),
221
+ alias='Thought',
222
+ )
223
+ action: str = Field(
224
+ default="",
225
+ description=(
226
+ "REQUIRED. Must be one of the registered tool names shown "
227
+ "in the system prompt, OR the exact literal string "
228
+ "'Final_Answer' (with underscore) when you are ready to "
229
+ "return the completed answer. Never put natural-language "
230
+ "response text here — that goes in action_input."
231
+ ),
232
+ alias='action',
233
+ )
234
+ action_input: str | Dict | List = Field(
235
+ default="",
236
+ description=(
237
+ "REQUIRED. When action is a tool name, this is the JSON "
238
+ "object matching that tool's schema (or the single string "
239
+ "it expects). When action is 'Final_Answer', this is the "
240
+ "actual answer TEXT you want the user to see."
241
+ ),
242
+ alias='action_input',
243
+ )
178
244
 
179
245
  model_config = ConfigDict(populate_by_name=True)
180
246
 
@@ -198,14 +264,35 @@ class ChainOfThought(_ParserMixin, BaseModel):
198
264
  ``instance.action`` / ``instance.action_input`` uniformly across every
199
265
  AgentType — no more parser-specific field lookups.
200
266
  """
201
- Thought: str = Field('The step-by-step reasoning', alias='Thought')
267
+ Thought: str = Field(
268
+ default="",
269
+ description=(
270
+ "REQUIRED. Step-by-step reasoning covering: (a) what the "
271
+ "user asked; (b) what info is needed; (c) which tool fits; "
272
+ "(d) the exact argument shape; (e) whether one call finishes."
273
+ ),
274
+ alias='Thought',
275
+ )
202
276
  action: str = Field(
203
- "The action to take or Final Answer",
277
+ default="",
278
+ description=(
279
+ "REQUIRED. Must be one of the registered tool names shown "
280
+ "in the system prompt, OR the exact literal string "
281
+ "'Final_Answer' (with underscore) when you are ready to "
282
+ "return the completed answer. Never put natural-language "
283
+ "response text here — that goes in action_input."
284
+ ),
204
285
  validation_alias=AliasChoices("action", "Action"),
205
286
  serialization_alias="action",
206
287
  )
207
288
  action_input: str | Dict | List = Field(
208
- "The input to the action",
289
+ default="",
290
+ description=(
291
+ "REQUIRED. When action is a tool name, this is the JSON "
292
+ "object matching that tool's schema (or the single string "
293
+ "it expects). When action is 'Final_Answer', this is the "
294
+ "actual answer TEXT you want the user to see."
295
+ ),
209
296
  validation_alias=AliasChoices("action_input", "Action_Input"),
210
297
  serialization_alias="action_input",
211
298
  )
@@ -224,8 +311,27 @@ class ZeroShot(_ParserMixin, BaseModel):
224
311
  """
225
312
  A Pydantic model representing the Zero-Shot prompt template.
226
313
  """
227
- action: str = Field("The action to take", alias='action')
228
- action_input: str | Dict | List = Field("The input to the action", alias='action_input')
314
+ action: str = Field(
315
+ default="",
316
+ description=(
317
+ "REQUIRED. Must be one of the registered tool names shown "
318
+ "in the system prompt, OR the exact literal string "
319
+ "'Final_Answer' (with underscore) when you are ready to "
320
+ "return the completed answer. Never put natural-language "
321
+ "response text here — that goes in action_input."
322
+ ),
323
+ alias='action',
324
+ )
325
+ action_input: str | Dict | List = Field(
326
+ default="",
327
+ description=(
328
+ "REQUIRED. When action is a tool name, this is the JSON "
329
+ "object matching that tool's schema (or the single string "
330
+ "it expects). When action is 'Final_Answer', this is the "
331
+ "actual answer TEXT you want the user to see."
332
+ ),
333
+ alias='action_input',
334
+ )
229
335
 
230
336
  model_config = ConfigDict(populate_by_name=True)
231
337
 
@@ -241,8 +347,27 @@ class FewShot(_ParserMixin, BaseModel):
241
347
  """
242
348
  A Pydantic model representing the Few-Shot prompt template.
243
349
  """
244
- action: str = Field("The action to take", alias='action')
245
- action_input: str | Dict | List = Field("The input to the action", alias='action_input')
350
+ action: str = Field(
351
+ default="",
352
+ description=(
353
+ "REQUIRED. Must be one of the registered tool names shown "
354
+ "in the system prompt, OR the exact literal string "
355
+ "'Final_Answer' (with underscore) when you are ready to "
356
+ "return the completed answer. Never put natural-language "
357
+ "response text here — that goes in action_input."
358
+ ),
359
+ alias='action',
360
+ )
361
+ action_input: str | Dict | List = Field(
362
+ default="",
363
+ description=(
364
+ "REQUIRED. When action is a tool name, this is the JSON "
365
+ "object matching that tool's schema (or the single string "
366
+ "it expects). When action is 'Final_Answer', this is the "
367
+ "actual answer TEXT you want the user to see."
368
+ ),
369
+ alias='action_input',
370
+ )
246
371
 
247
372
  model_config = ConfigDict(populate_by_name=True)
248
373
 
@@ -114,6 +114,34 @@ class CircuitBreaker:
114
114
  _NO_ARG = object()
115
115
 
116
116
 
117
+ # Set of case-insensitive strings that should be treated as the model's
118
+ # "I'm done, return this as the final answer" signal. Exact string
119
+ # "Final_Answer" is the canonical form the prompt template teaches;
120
+ # the tolerant variants exist because under use_function_calling=True
121
+ # the schema doesn't constrain `action` to any specific value, so
122
+ # LLMs (especially gpt-4o-mini) emit "Final Answer" (with space) or
123
+ # "final_answer" (lowercase) or "finalanswer" (no separator). Any of
124
+ # those should end the loop, not get routed to Tool_Runner.
125
+ #
126
+ # Deliberately excludes bare "answer" or "final" — those are too
127
+ # likely to collide with a user-defined tool of the same name.
128
+ _TERMINAL_ACTION_VARIANTS = frozenset({
129
+ "final_answer", "finalanswer",
130
+ })
131
+
132
+
133
+ def _is_terminal_action(action: str) -> bool:
134
+ """True if ``action`` should end the loop with action_input as the
135
+ final answer. Accepts Final_Answer, Final Answer, final_answer,
136
+ FINAL_ANSWER, finalanswer — any capitalization + underscore/space
137
+ variant. Empty string is not terminal (caller handles that as
138
+ 'unrecognized action' via the implicit-final guardrail)."""
139
+ if not action or not isinstance(action, str):
140
+ return False
141
+ normalized = action.strip().lower().replace(" ", "_").replace("-", "_")
142
+ return normalized in _TERMINAL_ACTION_VARIANTS
143
+
144
+
117
145
  def _build_sandbox_hint(perms: Any) -> Optional[str]:
118
146
  """Generate a short block describing where the runner is allowed to
119
147
  operate on disk. Injected into the system prompt when Permissions
@@ -1019,6 +1047,7 @@ class AgentRunner:
1019
1047
  permissions: Any = None,
1020
1048
  include_denied_tools: bool = False,
1021
1049
  system_addendum: Optional[str] = None,
1050
+ strict_tool_dispatch: bool = False,
1022
1051
  ):
1023
1052
  # PEP 8 alias: prefer lowercase `agent=`. Either argument works; passing
1024
1053
  # both raises so misuse is loud rather than silently ambiguous.
@@ -1104,6 +1133,16 @@ class AgentRunner:
1104
1133
  else:
1105
1134
  self.system_addendum = system_addendum
1106
1135
 
1136
+ # When True: on unknown-tool dispatch, feed the model an error
1137
+ # observation ("that tool doesn't exist; use one of [X,Y] or
1138
+ # Final_Answer") and let the loop iterate rather than
1139
+ # immediately falling to implicit-final. Only applies when the
1140
+ # unknown action looks like a tool identifier (no spaces) —
1141
+ # natural-language actions still go straight to implicit-final
1142
+ # since retrying makes no sense there. Default False keeps
1143
+ # the 3.0.4 behavior.
1144
+ self.strict_tool_dispatch = strict_tool_dispatch
1145
+
1107
1146
  self.auto_cache = auto_cache and config.caching_enabled
1108
1147
  self.auto_memory = auto_memory and config.memory_enabled
1109
1148
  _setup = _ensure_auto_setup() if (self.auto_cache or self.auto_memory) else None
@@ -1323,53 +1362,38 @@ class AgentRunner:
1323
1362
  step_description = f"Step {count}: {action} with {action_input}"
1324
1363
  steps.append(step_description)
1325
1364
 
1326
- if action == "Final_Answer":
1365
+ # Terminal action check — tolerant matching so the loop ends
1366
+ # for the canonical "Final_Answer" AND for the LLM's common
1367
+ # variants ("Final Answer", "final_answer", "finalanswer").
1368
+ # Under use_function_calling=True the schema doesn't
1369
+ # constrain `action` to any spelling, so we accept them all.
1370
+ if _is_terminal_action(action):
1327
1371
  final_answer = action_input
1328
1372
  yield {"type": "final", "content": final_answer}
1329
1373
  break
1330
1374
 
1331
- # Guardrail for use_function_calling=True: the model is forced
1332
- # to call the parser tool (React_ / FewShot / etc.) and fills
1333
- # in `action` + `action_input`. Nothing in the parser schema
1334
- # constrains `action` to a real tool name, so the model can
1335
- # (and does) put its natural-language closing response there
1336
- # e.g. action = "Provide a response to the athlete."
1337
- # Without this guardrail the runner would try to dispatch
1338
- # a tool with that name, get "not found", and the turn dies.
1339
- # Detection: action isn't Final_Answer AND isn't in the
1340
- # registry. Treat the whole turn as an implicit Final_Answer
1341
- # built from whatever text the model provided (thought +
1342
- # action + action_input, whichever are present).
1343
- known_tools = set(self.func) | set(self.args)
1344
- if action and action != "Final_Answer" and action not in known_tools:
1345
- parts: List[str] = []
1346
- if thought:
1347
- parts.append(str(thought))
1348
- # action itself may BE the response text
1349
- if action:
1350
- parts.append(str(action))
1351
- # action_input often carries the actual body
1352
- if action_input and str(action_input).strip():
1353
- parts.append(str(action_input))
1354
- final_answer = "\n\n".join(parts) or (
1355
- "(agent emitted an unrecognized action and no text)"
1356
- )
1375
+ # Normalize BEFORE the known-tools check — OpenAI's FC path
1376
+ # sometimes returns tool names with a "functions." prefix
1377
+ # ("functions.get_my_scores" instead of "get_my_scores").
1378
+ # Without this, a valid tool call falls through to the
1379
+ # implicit-final guardrail, which then leaks the model's
1380
+ # internal Thought into user-visible text. Rewrite `action`
1381
+ # here so the rest of the loop uses the canonical name.
1382
+ normalized_action = self.registry._normalize_tool_name(action)
1383
+ if normalized_action != action:
1357
1384
  if self.verbose:
1358
1385
  print(
1359
- f"\x1B[1;33m[loop] implicit-final: action "
1360
- f"'{action[:60]}' is not a registered tool; "
1361
- f"treating turn as Final_Answer\x1B[0m"
1386
+ f"\x1B[3;33m[loop] normalized action "
1387
+ f"'{action}' -> '{normalized_action}'\x1B[0m"
1362
1388
  )
1363
- yield {"type": "final", "content": final_answer}
1364
- break
1365
-
1366
- # Loop-level spiral check same (action, args) as the last
1367
- # turn? Count it. Three in a row and we bail with a synthesized
1368
- # Final_Answer built from whatever real data was gathered
1369
- # before the spiral. Catches BOTH the "refused" spiral and
1370
- # the "tool not found" spiral (gpt-4o-mini did both in
1371
- # traces). Cheaper than dispatch — we abort before the tool
1372
- # even runs on the 3rd repeat.
1389
+ action = normalized_action
1390
+
1391
+ # Loop-level spiral check — 3 identical (action, args) in a
1392
+ # row and we bail. Placed BEFORE the known-tools branch so
1393
+ # it catches BOTH kinds of spiral: a valid tool getting
1394
+ # re-dispatched (dup-guard also catches this at the tool
1395
+ # layer), and an unknown tool being retried via strict-mode
1396
+ # (dup-guard would never see those since dispatch skips them).
1373
1397
  try:
1374
1398
  action_sig = f"{action}::{json.dumps(action_input, sort_keys=True, default=repr)}"
1375
1399
  except Exception:
@@ -1404,6 +1428,102 @@ class AgentRunner:
1404
1428
  yield {"type": "final", "content": final_answer}
1405
1429
  break
1406
1430
 
1431
+ # Guardrail: the model emitted an `action` value that is
1432
+ # neither a terminal-answer variant NOR a registered tool
1433
+ # (even after normalization). Two scenarios trigger this:
1434
+ # 1. use_function_calling=True + missing schema guidance:
1435
+ # the model stuffs its natural-language response text
1436
+ # into `action` (e.g. "Provide a response to the
1437
+ # athlete."). Route to final so the loop terminates.
1438
+ # 2. action="" (missing field, Pydantic default).
1439
+ # NEVER include `Thought` in the user-facing final — the
1440
+ # Thought is internal reasoning ("The user needs their
1441
+ # scores; I'll fetch them") and leaking it to the user
1442
+ # exposes the agent's introspection. Prefer action_input
1443
+ # (that's what the model wanted the user to see). Only fall
1444
+ # back to `action` when action_input is empty AND action
1445
+ # looks like natural-language text (contains a space, not
1446
+ # just an identifier).
1447
+ known_tools = set(self.func) | set(self.args)
1448
+ if not action or action not in known_tools:
1449
+ # Strict mode: when the unknown `action` looks like a
1450
+ # tool identifier (no spaces, short), assume the model
1451
+ # was TRYING to call a tool and misnamed it. Feed an
1452
+ # error observation back so the model can retry with
1453
+ # a valid name, instead of collapsing to implicit-final
1454
+ # after the very first mistake. The existing dup-guard
1455
+ # + loop-level circuit breaker still bound retries.
1456
+ # Only skipped when: strict mode is off, we've hit
1457
+ # max_iterations, or the action is clearly the model's
1458
+ # response text (contains a space → natural language).
1459
+ looks_like_tool_id = (
1460
+ bool(action) and " " not in action and len(action) < 80
1461
+ )
1462
+ if (
1463
+ self.strict_tool_dispatch
1464
+ and looks_like_tool_id
1465
+ and count < self.max_iterations
1466
+ ):
1467
+ available = sorted(known_tools)
1468
+ error_content = (
1469
+ f"[framework] error: '{action}' is not a "
1470
+ f"registered tool. Available tools: {available}. "
1471
+ f"Options: (1) call one of them with the correct "
1472
+ f"argument shape; (2) emit action=\"Final_Answer\" "
1473
+ f"with your final response text in action_input "
1474
+ f"if you're done."
1475
+ )
1476
+ tc_id = getattr(self, "_last_function_call_id", None) if self.use_function_calling else None
1477
+ err_msg = {
1478
+ "role": "tool" if tc_id else "function",
1479
+ "name": "tool_call_error",
1480
+ "content": error_content,
1481
+ }
1482
+ if tc_id:
1483
+ err_msg["tool_call_id"] = tc_id
1484
+ working_history.append(err_msg)
1485
+ yield {
1486
+ "type": "tool_result",
1487
+ "name": action,
1488
+ "result": error_content,
1489
+ "is_error": True,
1490
+ }
1491
+ if self.verbose:
1492
+ print(
1493
+ f"\x1B[1;33m[loop] strict-retry: action "
1494
+ f"'{action[:60]}' unknown; feeding error "
1495
+ f"back so the model can retry\x1B[0m"
1496
+ )
1497
+ self._last_function_call_id = None
1498
+ count += 1
1499
+ continue
1500
+
1501
+ # Non-strict OR strict-can't-help path: implicit-final.
1502
+ # Prefer action_input; fall back to action-as-text ONLY
1503
+ # when it's natural language (has spaces). NEVER leak
1504
+ # Thought — it's internal reasoning that shouldn't reach
1505
+ # a user, especially in third-person-persona apps.
1506
+ if action_input and str(action_input).strip():
1507
+ final_answer = str(action_input)
1508
+ elif action and action.strip() and " " in action:
1509
+ final_answer = str(action)
1510
+ else:
1511
+ final_answer = (
1512
+ "(agent emitted an unrecognized action and no "
1513
+ "answer text — try rephrasing or re-run)"
1514
+ )
1515
+ if self.verbose:
1516
+ preview = (action or "<empty>")[:60]
1517
+ print(
1518
+ f"\x1B[1;33m[loop] implicit-final: action "
1519
+ f"'{preview}' is not a registered tool and not "
1520
+ f"a Final_Answer variant; returning "
1521
+ f"action_input as final (Thought NOT leaked)"
1522
+ f"\x1B[0m"
1523
+ )
1524
+ yield {"type": "final", "content": final_answer}
1525
+ break
1526
+
1407
1527
  yield {"type": "tool_call", "name": action, "args": action_input}
1408
1528
  if self.verbose:
1409
1529
  print(f"\x1B[3;33m[tool] Invoking '{action}' with args: {action_input}\x1B[0m")
@@ -13,7 +13,7 @@ from agentx_dev.ChatModel import BaseChatModel
13
13
  from agentx_dev.Agents.Agent import StandardParser, ToolCall, ToolError
14
14
  from agentx_dev.Tools import StandardTool, StructuredTool, logger
15
15
  from agentx_dev.AsyncTools import AsyncStandardTool, AsyncStructuredTool
16
- from agentx_dev.Runner.AgentRun import ToolRegistry
16
+ from agentx_dev.Runner.AgentRun import ToolRegistry, _is_terminal_action
17
17
  from typing import Dict, Callable, List, Type, Optional, Any, AsyncIterator
18
18
  from pydantic import BaseModel, Field
19
19
  import asyncio
@@ -86,6 +86,7 @@ class AsyncAgentRunner:
86
86
  agent: AgentFormattor | AgentPrompt | str | None = None,
87
87
  permissions: Any = None,
88
88
  include_denied_tools: bool = False,
89
+ strict_tool_dispatch: bool = False,
89
90
  ):
90
91
  if Agent is not None and agent is not None:
91
92
  raise TypeError("AsyncAgentRunner received both 'Agent' and 'agent'; pass only one.")
@@ -133,6 +134,8 @@ class AsyncAgentRunner:
133
134
  "are mutually exclusive. Function-calling routes through the "
134
135
  "AgentType parser; native binding skips it. Pick one."
135
136
  )
137
+ # See AgentRunner.__init__ for the strict_tool_dispatch contract.
138
+ self.strict_tool_dispatch = strict_tool_dispatch
136
139
 
137
140
  self.auto_cache = auto_cache and config.caching_enabled
138
141
  self.auto_memory = auto_memory and config.memory_enabled
@@ -493,7 +496,10 @@ class AsyncAgentRunner:
493
496
  step_description = f"Step {count}: {action} with {action_input}"
494
497
  steps.append(step_description)
495
498
 
496
- if action == "Final_Answer":
499
+ # Terminal-action check — tolerant of format variants that
500
+ # LLMs emit under use_function_calling=True where the schema
501
+ # doesn't constrain `action` to a specific spelling.
502
+ if _is_terminal_action(action):
497
503
  final_answer = action_input
498
504
  break
499
505
 
@@ -503,6 +509,82 @@ class AsyncAgentRunner:
503
509
  elif thought:
504
510
  logger.info(f"thought: {thought}")
505
511
 
512
+ # Strip OpenAI's "functions." prefix before the known-tools
513
+ # check — otherwise a valid tool call falls through to the
514
+ # implicit-final guardrail and leaks Thought as user text.
515
+ normalized_action = self.registry._normalize_tool_name(action)
516
+ if normalized_action != action:
517
+ if self.verbose:
518
+ print(
519
+ f"\x1B[3;33m[loop] normalized action "
520
+ f"'{action}' -> '{normalized_action}'\x1B[0m"
521
+ )
522
+ action = normalized_action
523
+
524
+ # Implicit-final guardrail — see the sync AgentRunner for
525
+ # the full rationale. If strict_tool_dispatch=True AND the
526
+ # unknown action looks like a tool identifier (no spaces)
527
+ # AND we still have iteration budget, feed an error
528
+ # observation back to the model and let it retry.
529
+ known_tools = set(self.registry.sync_std) | set(self.registry.sync_struct) \
530
+ | set(self.registry.async_std) | set(self.registry.async_struct)
531
+ if not action or action not in known_tools:
532
+ looks_like_tool_id = (
533
+ bool(action) and " " not in action and len(action) < 80
534
+ )
535
+ if (
536
+ self.strict_tool_dispatch
537
+ and looks_like_tool_id
538
+ and count < self.max_iterations
539
+ ):
540
+ available = sorted(known_tools)
541
+ error_content = (
542
+ f"[framework] error: '{action}' is not a "
543
+ f"registered tool. Available tools: {available}. "
544
+ f"Options: (1) call one of them with the correct "
545
+ f"argument shape; (2) emit action=\"Final_Answer\" "
546
+ f"with your final response text in action_input "
547
+ f"if you're done."
548
+ )
549
+ tc_id = getattr(self, "_last_function_call_id", None) if self.use_function_calling else None
550
+ err_msg = {
551
+ "role": "tool" if tc_id else "function",
552
+ "name": "tool_call_error",
553
+ "content": error_content,
554
+ }
555
+ if tc_id:
556
+ err_msg["tool_call_id"] = tc_id
557
+ working_history.append(err_msg)
558
+ if self.verbose:
559
+ print(
560
+ f"\x1B[1;33m[loop] strict-retry: action "
561
+ f"'{action[:60]}' unknown; feeding error "
562
+ f"back so the model can retry\x1B[0m"
563
+ )
564
+ self._last_function_call_id = None
565
+ count += 1
566
+ continue
567
+
568
+ # Non-strict OR strict-can't-help: implicit-final.
569
+ if action_input and str(action_input).strip():
570
+ final_answer = str(action_input)
571
+ elif action and action.strip() and " " in action:
572
+ final_answer = str(action)
573
+ else:
574
+ final_answer = (
575
+ "(agent emitted an unrecognized action and no "
576
+ "answer text — try rephrasing or re-run)"
577
+ )
578
+ if self.verbose:
579
+ preview = (action or "<empty>")[:60]
580
+ print(
581
+ f"\x1B[1;33m[loop] implicit-final: action "
582
+ f"'{preview}' is not a registered tool and not "
583
+ f"a Final_Answer variant; returning action_input "
584
+ f"as final (Thought NOT leaked)\x1B[0m"
585
+ )
586
+ break
587
+
506
588
  if self.verbose:
507
589
  print(f"\x1B[3;33m[tool] Invoking '{action}' with args: {action_input}\x1B[0m")
508
590
  else:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentx-dev
3
- Version: 3.0.2
3
+ Version: 3.0.5
4
4
  Summary: A lightweight LLM agent framework with multi-agent orchestration (Supervisor + dynamic specialist spawning), permission-gated default tools, MCP integration, and prompt-template presets.
5
5
  Author-email: Bruce-Arhin Shadrach <brucearhin098@gmail.com>
6
6
  License: MIT License
@@ -1,4 +1,3 @@
1
1
  agentx_dev
2
2
  dist
3
3
  examples
4
- test_dl
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
 
3
3
  name = "agentx-dev"
4
- version = "3.0.2"
4
+ version = "3.0.5"
5
5
  description = "A lightweight LLM agent framework with multi-agent orchestration (Supervisor + dynamic specialist spawning), permission-gated default tools, MCP integration, and prompt-template presets."
6
6
  authors = [{ name = "Bruce-Arhin Shadrach", email = "brucearhin098@gmail.com" }]
7
7
  # Best practice: link to the license file
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes