agentling/feature/agent#4
Conversation
folathecoder
commented
Jul 6, 2026
- Add agent.py: the async ReAct loop (Agent)
- Add end-to-end agent tests with a scripted FakeModel (7 tests)
Assemble Model + Tools + Memory into an async agent. A single async generator (_run_stream) drives the model/tool cycle and yields Events; run() dispatches — stream=True returns that stream, stream=False drains it to the final answer. - forgiving termination (no tool calls) and explicit final_answer - parallel tool execution via asyncio.gather (parallel_tools flag) - tool failures captured as error observations (self-heal), never crashes - ActionStep records model_message/tool_results/usage/timing; step_callbacks fire - max_steps exhausted -> one forced tool-free answer
PR Summary by QodoAdd async ReAct Agent loop with event streaming and end-to-end tests
AI Description
Diagram
High-Level Assessment
Files changed (2)
|
Code Review by Qodo
1. Final tool runs other tools
|
| if self.parallel_tools: | ||
| results: list[ToolResult] = await asyncio.gather( | ||
| *(self._execute_tool(tc) for tc in response.tool_calls) | ||
| ) | ||
| else: | ||
| results = [await self._execute_tool(tc) for tc in response.tool_calls] | ||
|
|
||
| for result in results: | ||
| yield ToolResultEvent(result=result) | ||
|
|
||
| action = ActionStep( | ||
| model_message=response, | ||
| tool_results=results, | ||
| usage=response.usage, | ||
| duration=time.monotonic() - started, | ||
| ) | ||
| self.memory.add(action) | ||
| for callback in self.step_callbacks: | ||
| callback(action) | ||
| yield StepEvent(step=action) | ||
|
|
||
| # Explicit termination: the model called final_answer (successfully). | ||
| final = next( | ||
| (r for r in results if r.name == "final_answer" and not r.is_error), | ||
| None, | ||
| ) | ||
| if final is not None: | ||
| self.memory.add(FinalStep(answer=final.content)) | ||
| yield FinalEvent(answer=final.content, usage=response.usage) | ||
| return |
There was a problem hiding this comment.
1. Final tool runs other tools 🐞 Bug ≡ Correctness
If the model emits final_answer alongside other tool calls, the agent will still execute all tools (potentially side-effectful) before terminating, because termination is checked only after tool execution completes.
Agent Prompt
## Issue description
When the model requests multiple tool calls in a single turn and one of them is `final_answer`, the agent currently executes *all* tool calls before checking for termination. This can trigger unintended side effects (e.g., sending emails, deleting resources) even though the model has already decided to end the run.
## Issue Context
`final_answer` is explicitly documented as the terminating tool, so it should prevent execution of other tools in the same turn (or at minimum it should be executed last and cancel/skip the rest).
## Fix Focus Areas
- src/agentling/agent.py[109-142]
- src/agentling/tools.py[358-384]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| # Step limit reached: force one tool-free answer. | ||
| messages = self.memory.to_messages(self.instructions) | ||
| messages.append( | ||
| ChatMessage( | ||
| role="user", | ||
| content="Step limit reached. Give your best final answer now.", | ||
| ) | ||
| ) | ||
| response = await self.model.generate(messages) | ||
| self.memory.add(FinalStep(answer=response.content)) | ||
| yield FinalEvent(answer=response.content, usage=response.usage) |
There was a problem hiding this comment.
2. Forced prompt not in memory 🐞 Bug ≡ Correctness
On max-steps fallback, the extra user message (“Step limit reached…”) is appended directly to the messages list and not recorded as a Step, so Memory/serialization/replay no longer reflects the exact conversation sent to the model.
Agent Prompt
## Issue description
When the step limit is reached, the agent appends a synthetic user prompt to the outgoing message list without storing it in `Memory`. This creates divergence between:
- what the model actually saw, and
- what `Memory.to_messages()` / `Memory.dump_json()` can reproduce.
This breaks the stated “persistence/replay” goal of Memory and makes debugging runs harder.
## Issue Context
Memory is designed to be the durable, structured record of a run and is the source of truth for message rendering and JSON round-tripping.
## Fix Focus Areas
- src/agentling/agent.py[144-154]
- src/agentling/memory.py[97-167]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| self.memory.add(TaskStep(task=task)) | ||
|
|
||
| limit = max_steps or self.max_steps | ||
| for _ in range(limit): | ||
| started = time.monotonic() | ||
| messages = self.memory.to_messages(self.instructions) | ||
| response = await self.model.generate(messages, tools=self._tool_schemas) | ||
|
|
||
| # Forgiving termination: a turn with no tool calls IS the final answer. | ||
| if not response.tool_calls: | ||
| self.memory.add(FinalStep(answer=response.content)) | ||
| yield FinalEvent(answer=response.content, usage=response.usage) | ||
| return |
There was a problem hiding this comment.
3. Stepevent contract mismatch 🐞 Bug ◔ Observability
StepEvent is described as “emitted after a step has been recorded to memory,” but the agent only yields StepEvent for ActionStep and not when recording TaskStep or FinalStep, making the stream incomplete relative to memory updates.
Agent Prompt
## Issue description
The agent’s event stream emits `StepEvent` only for `ActionStep`. However, `TaskStep` and `FinalStep` are also added to memory without emitting a corresponding `StepEvent`, which can confuse streaming consumers that use `StepEvent` to keep an in-sync view of Memory.
If the intended contract is “StepEvent == any step recorded,” emit StepEvent for TaskStep/FinalStep too. If the intent is “StepEvent == action steps only,” update the docs/type names to match.
## Issue Context
The events module documents StepEvent as being emitted after a step is recorded, without restricting it to action steps.
## Fix Focus Areas
- src/agentling/agent.py[95-107]
- src/agentling/agent.py[103-107]
- src/agentling/agent.py[139-154]
- src/agentling/events.py[7-45]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| messages = self.memory.to_messages(self.instructions) | ||
| response = await self.model.generate(messages, tools=self._tool_schemas) | ||
|
|
There was a problem hiding this comment.
4. Model exceptions crash agent 🐞 Bug ☼ Reliability
Exceptions from model.generate() propagate and abort the run (including tool-argument parsing failures in the OpenAI adapter), unlike tool failures which are converted into error observations.
Agent Prompt
## Issue description
`Agent._run_stream()` awaits `model.generate()` without any exception handling. If the model adapter raises (network failure, provider error, or tool-argument parsing `ValueError`), the agent crashes and yields no structured failure event/step.
This is inconsistent with the tool execution layer, which deliberately converts tool exceptions into recoverable observations.
## Issue Context
The OpenAI adapter explicitly raises `ValueError` on invalid tool-call JSON (“fail loudly so the caller can decide how to handle the error”), so the Agent should decide (e.g., record an ActionStep with error and either retry, request a tool-free response, or terminate with a structured FinalEvent).
## Fix Focus Areas
- src/agentling/agent.py[97-107]
- src/agentling/agent.py[144-154]
- src/agentling/models.py[338-356]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools