Skip to content

Day 5: self-healing, interruption, and model-API retry#5

Merged
folathecoder merged 5 commits into
mainfrom
agentling/feature/self-healing
Jul 7, 2026
Merged

Day 5: self-healing, interruption, and model-API retry#5
folathecoder merged 5 commits into
mainfrom
agentling/feature/self-healing

Conversation

@folathecoder

Copy link
Copy Markdown
Owner

Implements FOL-42: production guardrails on top of the agent loop.

  • Loop detector: an exact (tool, args) repeat of the previous step appends a
    "change your approach" note to the observation so the model breaks the loop.
  • Interruption: interrupt() sets an asyncio.Event; the run pauses gracefully
    (yields "Run interrupted.", records no FinalStep) and run(reset=False)
    resumes from the steps already in memory.
  • Self-heal layer 3: model-API retry now covers transient errors (rate limit,
    connection and timeout, 5xx) with exponential backoff, aborting after N.
    Permanent errors such as bad request or bad auth fail fast.
  • 8 new tests (65 total); mypy and ruff clean.
  • Small comment cleanup across agent.py, models.py, and memory.py.

Follow-ups filed during the coverage audit: FOL-45 (correctness issues) and
FOL-46 (exhaustive test coverage).

Closes FOL-42.

The loop detector fingerprints each step's (tool, args) and, when a step
repeats the previous one exactly, appends a "change your approach" note to
the tool observation so the model breaks out of a rut.

interrupt() sets an asyncio.Event that is checked at the top of each step.
When set, the run yields a "Run interrupted." FinalEvent and stops without
recording a FinalStep, so run(reset=False) resumes from the steps already in
memory. This is a graceful pause between steps, not a mid-await crash.

Also tidies this file's comments to plain prose and drops the numbered
process markers.
_create_with_retry now retries rate limits, connection and timeout errors,
and 5xx responses with exponential backoff, then re-raises once max_retries
is exhausted. That is the agent's one fatal path. Permanent errors such as a
bad request or bad auth are not retried and propagate immediately.

Also tidies the module and method docstrings to plain prose.
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Add loop detection, interruption, and transient model-API retry guardrails

✨ Enhancement 🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Detect exact repeated tool calls and nudge the model to change approach.
• Add a graceful interrupt/resume mechanism for pausing runs between steps.
• Expand OpenAI API retries for transient failures and add targeted test coverage.
Diagram

sequenceDiagram
  actor C as Caller
  participant A as Agent
  participant Mem as Memory
  participant M as Model
  participant OA as OpenAI API
  participant T as Tools

  C->>A: run(task, reset/stream/max_steps)
  A->>Mem: add(TaskStep)

  loop Each step
    A->>A: check interrupt flag
    alt interrupted
      A-->>C: FinalEvent("Run interrupted.")
    else not interrupted
      A->>M: generate(messages, tools)
      M->>OA: create() (retry + backoff on transient)
      OA-->>M: response / error
      M-->>A: assistant message + tool_calls
      A->>T: execute tool_calls
      T-->>A: ToolResult(s)
      alt repeated tool signature
        A->>A: append "change approach" nudge
      end
      A->>Mem: add(ActionStep)
    end
  end

  A-->>C: FinalEvent(answer)
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use a dedicated retry library (e.g., tenacity) with jitter
  • ➕ Standardized backoff/jitter behavior and richer configuration
  • ➕ Easier to extend with per-exception policies and observability hooks
  • ➖ Adds dependency and more abstraction than needed for a small framework
  • ➖ Harder to reason about exact retry semantics when debugging
2. Generalize loop detection to a sliding window (N-step repeats)
  • ➕ Catches longer cycles (e.g., A→B→A→B) beyond immediate repeats
  • ➕ Could reduce wasted tokens in more complex ruts
  • ➖ More state/memory and higher false-positive risk
  • ➖ Requires careful tuning to avoid suppressing legitimate repeated calls

Recommendation: The PR’s approach is appropriate for “Day 5 guardrails”: immediate-repeat loop detection is low-risk and cheap, the interrupt mechanism is intentionally cooperative (between steps) to avoid mid-await cancellation hazards, and the expanded transient retry list matches common operational failures. Consider adding jitter later and/or extending loop detection beyond the previous step only if real-world traces show persistent multi-step cycles.

Files changed (5) +245 / -28

Enhancement (1) +54 / -14
agent.pyAdd interruption support and exact-repeat loop nudge in the agent loop +54/-14

Add interruption support and exact-repeat loop nudge in the agent loop

• Introduces a cooperative interrupt flag that stops the run before the next step and allows resuming with reset=False. Adds a loop detector by fingerprinting tool calls (name + canonical JSON args) and appending a “change approach” nudge to tool observations when the signature exactly repeats the prior step.

src/agentling/agent.py

Bug fix (1) +16 / -9
models.pyRetry transient OpenAI API failures with exponential backoff +16/-9

Retry transient OpenAI API failures with exponential backoff

• Broadens _create_with_retry to retry rate limits, connection/timeouts, and 5xx (InternalServerError) with exponential backoff, while letting permanent errors fail fast. Updates docstrings to reflect the new retry policy and its limitations (no mid-stream retry).

src/agentling/models.py

Tests (2) +170 / -0
test_agent.pyAdd tests for loop detector, interruption/resume, and tool-error recovery +132/-0

Add tests for loop detector, interruption/resume, and tool-error recovery

• Adds focused async tests validating (1) loop nudge on exact repeat vs different args, (2) graceful interrupt halting without a FinalStep and resume via reset=False, and (3) recovery paths for unknown tools and tools that raise exceptions.

tests/test_agent.py

test_models.pyTest transient retry vs permanent fail-fast behavior +38/-0

Test transient retry vs permanent fail-fast behavior

• Adds tests ensuring a transient connection error is retried and succeeds on the next attempt, while a BadRequestError is not retried and fails immediately.

tests/test_models.py

Documentation (1) +5 / -5
memory.pyDocstring cleanup for memory/step model +5/-5

Docstring cleanup for memory/step model

• Rewords the module docstring for clarity and consistent prose without changing behavior.

src/agentling/memory.py

@folathecoder folathecoder merged commit 11d15a3 into main Jul 7, 2026
2 checks passed
@folathecoder folathecoder deleted the agentling/feature/self-healing branch July 7, 2026 04:51
@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Loop nudge overclaims result 🐞 Bug ≡ Correctness
Description
Agent._run_stream() appends a note saying the repeated tool call “got the same result,” but the loop
detector only compares the (tool name, args) signature and never checks whether the tool output
actually matched the previous step. This can mislead the model (and anyone inspecting observations)
when tools are non-deterministic or when repeating a call is intentional.
Code

src/agentling/agent.py[R129-139]

+            # Fingerprint this step's calls: (name, canonical JSON args) each.
+            signature = tuple(
+                (tc.name, json.dumps(tc.arguments, sort_keys=True))
+                for tc in response.tool_calls
+            )
+            looping = signature == previous_signature
+            previous_signature = signature
+
            # Announce every call, then run them (concurrently or in order).
            for tool_call in response.tool_calls:
                yield ToolCallEvent(tool_call=tool_call)
Evidence
The code defines a nudge claiming “got the same result,” but the loop condition is derived purely
from repeating the same tool call signature and does not compare prior ToolResult content anywhere.

src/agentling/agent.py[29-32]
src/agentling/agent.py[129-151]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The loop detector sets `looping` based only on an exact repeat of tool-call `(name, args)`, but `_LOOP_NUDGE` claims the call also “got the same result.” This is stronger than what the code verifies and can provide incorrect guidance.

### Issue Context
- `looping` is computed from the tool-call signature only.
- The nudge text is appended unconditionally whenever `looping` is true.

### Fix Focus Areas
- src/agentling/agent.py[29-35]
- src/agentling/agent.py[129-151]

### Suggested fix
Either:
1) Change `_LOOP_NUDGE` wording to avoid asserting “same result” (e.g., “you already made this exact tool call; try a different approach”), **or**
2) Track the previous step’s tool results (e.g., store `(signature, results_content)`), and only append the “same result” phrasing when both signature and results content match.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Interrupt bypasses forced-final 🐞 Bug ☼ Reliability
Description
The interrupt flag is only checked inside the main step loop; if interruption is requested late
(e.g., during the last iteration), the agent can still execute the post-loop “step limit reached”
forced final-answer model.generate() call. This triggers an unwanted extra model request after the
run was asked to stop.
Code

src/agentling/agent.py[R114-117]

+            if self._interrupt.is_set():
+                self._interrupt.clear()
+                yield FinalEvent(answer="Run interrupted.")
+                return
Evidence
The agent clears/returns on interrupt only at the top of the main loop, while the step-limit
forced-final-answer branch makes a model call without checking _interrupt again.

src/agentling/agent.py[113-118]
src/agentling/agent.py[176-186]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`interrupt()` is intended to stop the run before the next step, but the implementation only checks `_interrupt` at the start of each `for _ in range(limit)` iteration. If an interrupt is set during the last loop iteration, control can fall through to the step-limit forced-final-answer path, which still calls `model.generate()`.

### Issue Context
After the main loop exits, the agent unconditionally executes the forced-final-answer branch. This branch should also honor the interrupt request.

### Fix Focus Areas
- src/agentling/agent.py[113-118]
- src/agentling/agent.py[176-186]

### Suggested fix
Before executing the post-loop forced-final-answer `model.generate()`, add the same interrupt guard used in the main loop:
- if `_interrupt.is_set()`: clear it, yield `FinalEvent(answer="Run interrupted.")`, and return.
This ensures interruption is honored consistently even when the run is about to perform the forced final model call.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread src/agentling/agent.py
Comment on lines +129 to 139
# Fingerprint this step's calls: (name, canonical JSON args) each.
signature = tuple(
(tc.name, json.dumps(tc.arguments, sort_keys=True))
for tc in response.tool_calls
)
looping = signature == previous_signature
previous_signature = signature

# Announce every call, then run them (concurrently or in order).
for tool_call in response.tool_calls:
yield ToolCallEvent(tool_call=tool_call)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Loop nudge overclaims result 🐞 Bug ≡ Correctness

Agent._run_stream() appends a note saying the repeated tool call “got the same result,” but the loop
detector only compares the (tool name, args) signature and never checks whether the tool output
actually matched the previous step. This can mislead the model (and anyone inspecting observations)
when tools are non-deterministic or when repeating a call is intentional.
Agent Prompt
### Issue description
The loop detector sets `looping` based only on an exact repeat of tool-call `(name, args)`, but `_LOOP_NUDGE` claims the call also “got the same result.” This is stronger than what the code verifies and can provide incorrect guidance.

### Issue Context
- `looping` is computed from the tool-call signature only.
- The nudge text is appended unconditionally whenever `looping` is true.

### Fix Focus Areas
- src/agentling/agent.py[29-35]
- src/agentling/agent.py[129-151]

### Suggested fix
Either:
1) Change `_LOOP_NUDGE` wording to avoid asserting “same result” (e.g., “you already made this exact tool call; try a different approach”), **or**
2) Track the previous step’s tool results (e.g., store `(signature, results_content)`), and only append the “same result” phrasing when both signature and results content match.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread src/agentling/agent.py
Comment on lines +114 to +117
if self._interrupt.is_set():
self._interrupt.clear()
yield FinalEvent(answer="Run interrupted.")
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Interrupt bypasses forced-final 🐞 Bug ☼ Reliability

The interrupt flag is only checked inside the main step loop; if interruption is requested late
(e.g., during the last iteration), the agent can still execute the post-loop “step limit reached”
forced final-answer model.generate() call. This triggers an unwanted extra model request after the
run was asked to stop.
Agent Prompt
### Issue description
`interrupt()` is intended to stop the run before the next step, but the implementation only checks `_interrupt` at the start of each `for _ in range(limit)` iteration. If an interrupt is set during the last loop iteration, control can fall through to the step-limit forced-final-answer path, which still calls `model.generate()`.

### Issue Context
After the main loop exits, the agent unconditionally executes the forced-final-answer branch. This branch should also honor the interrupt request.

### Fix Focus Areas
- src/agentling/agent.py[113-118]
- src/agentling/agent.py[176-186]

### Suggested fix
Before executing the post-loop forced-final-answer `model.generate()`, add the same interrupt guard used in the main loop:
- if `_interrupt.is_set()`: clear it, yield `FinalEvent(answer="Run interrupted.")`, and return.
This ensures interruption is honored consistently even when the run is about to perform the forced final model call.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant