Add result extension point and migrate repeat handling
Let extensions normalize or consume each completed model result before default history and tool processing. Move repeat detection into loop control and add a dedicated empty-response warning so one turn produces one UI warning.
linkliti committed
Aug 24, 2026 at 19:51 UTC
fd30f33d52b95b73bf195a51677d7bfd6b79010b
8 files changed
+160
-35
agent.py
+21
-34
@@ -491,40 +491,27 @@ class Agent:
491
492
await self.handle_intervention(agent_response)
493
494
- if (
495
- self.loop_data.last_response == agent_response
496
- ): # if assistant_response is the same as last message in history, let him know
497
- # Append the assistant's response to the history
498
- log_item = self.loop_data.params_temporary.get("log_item_generating")
499
- assistant_message = self.hist_add_ai_response(
500
- agent_response,
501
- id=log_item.id if log_item else "",
502
- llm_result=llm_result,
503
- )
504
- self._remember_llm_result_state(llm_result, assistant_message)
505
- # Append warning message to the history
506
- warning_msg = self.read_prompt("fw.msg_repeat.md")
507
- wmsg = self.hist_add_warning(message=warning_msg)
508
- PrintStyle(font_color="orange", padding=True).print(
509
- warning_msg
510
- )
511
- self.context.log.log(type="warning", content=warning_msg, id=wmsg.id)
512
-
513
- else: # otherwise proceed with tool
514
- # Append the assistant's response to the history
515
- log_item = self.loop_data.params_temporary.get("log_item_generating")
516
- assistant_message = self.hist_add_ai_response(
517
- agent_response,
518
- id=log_item.id if log_item else "",
519
- llm_result=llm_result,
520
- )
521
- self._remember_llm_result_state(llm_result, assistant_message)
522
- # process tools requested in agent message
523
- tools_result = await self.process_llm_result_tools(
524
- llm_result
525
- )
526
- if tools_result: # final response of message loop available
527
- return tools_result # break the execution if the task is done
494
+ result_data = {"llm_result": llm_result}
495
+ await extension.call_extensions_async(
496
+ "message_loop_result",
497
+ self,
498
+ loop_data=self.loop_data,
499
+ result_data=result_data,
500
+ )
501
+ if result_data.get("skip_default_processing"):
502
+ continue
503
+
504
+ agent_response = llm_result.response
505
+ log_item = self.loop_data.params_temporary.get("log_item_generating")
506
+ assistant_message = self.hist_add_ai_response(
507
+ agent_response,
508
+ id=log_item.id if log_item else "",
509
+ llm_result=llm_result,
510
+ )
511
+ self._remember_llm_result_state(llm_result, assistant_message)
512
+ tools_result = await self.process_llm_result_tools(llm_result)
513
+ if tools_result: # final response of message loop available
514
+ return tools_result # break the execution if the task is done
515
516
# exceptions inside message loop:
517
except Exception as e:
extensions/python/AGENTS.md
+1
@@ -44,6 +44,7 @@ Direct child DOX files:
44
| [hist_add_tool_result/AGENTS.md](hist_add_tool_result/AGENTS.md) | Tool-result history side effects. |
45
| [job_loop/AGENTS.md](job_loop/AGENTS.md) | Periodic backend maintenance jobs. |
46
| [message_loop_end/AGENTS.md](message_loop_end/AGENTS.md) | End-of-message-loop history and persistence behavior. |
47
+| [message_loop_result/AGENTS.md](message_loop_result/AGENTS.md) | Completed model-result normalization and default-processing interception. |
48
| [message_loop_prompts_after/AGENTS.md](message_loop_prompts_after/AGENTS.md) | Prompt protocol and extras assembled around message-loop prompt construction. |
49
| [message_loop_prompts_before/AGENTS.md](message_loop_prompts_before/AGENTS.md) | Pre-prompt-construction message-loop gates. |
50
| [message_loop_start/AGENTS.md](message_loop_start/AGENTS.md) | Start-of-message-loop iteration state. |
extensions/python/_functions/agent/Agent/hist_add_warning/end/_90_stop_unusable_response_loop.py
+1
@@ -22,6 +22,7 @@ class StopUnusableResponseLoop(Extension):
22
if message not in {
23
self.agent.read_prompt("fw.msg_misformat.md"),
24
self.agent.read_prompt("fw.msg_repeat.md"),
25
+ self.agent.read_prompt("fw.msg_empty_response.md"),
26
}:
27
return
28
extensions/python/message_loop_result/AGENTS.md
new
+28
@@ -0,0 +1,28 @@
1
+# Message Loop Result Extensions DOX
2
+
3
+## Purpose
4
+
5
+- Own normalization and policy handling after a model turn completes, before default assistant-history and tool-dispatch processing.
6
+
7
+## Ownership
8
+
9
+- Extensions receive mutable `result_data` with `llm_result` and may set `skip_default_processing` after fully handling the turn.
10
+
11
+## Local Contracts
12
+
13
+- Files run in deterministic filename order.
14
+- A handler that sets `skip_default_processing` owns needed history and UI side effects for that turn.
15
+- Do not use this point to mutate streamed partial content.
16
+
17
+## Work Guidance
18
+
19
+- Normalize a completed result before policy extensions compare or persist it.
20
+- Keep loop-control policy independent from optional plugins.
21
+
22
+## Verification
23
+
24
+- Run message-loop and unusable-response regression tests.
25
+
26
+## Child DOX Index
27
+
28
+No child DOX files.
extensions/python/message_loop_result/_20_loop_control.py
new
+43
@@ -0,0 +1,43 @@
1
+"""Handle completed model turns that should retry instead of dispatching tools."""
2
+
3
+from __future__ import annotations
4
+
5
+from typing import Any
6
+
7
+from helpers.extension import Extension
8
+from helpers.print_style import PrintStyle
9
+
10
+
11
+class LoopControl(Extension):
12
+ def execute(self, result_data: dict[str, Any] | None = None, **kwargs: Any) -> None:
13
+ if not self.agent or not isinstance(result_data, dict):
14
+ return
15
+
16
+ llm_result = result_data.get("llm_result")
17
+ response = getattr(llm_result, "response", "")
18
+ reasoning = getattr(llm_result, "reasoning", "")
19
+ if not isinstance(response, str) or not isinstance(reasoning, str):
20
+ return
21
+
22
+ if not response.strip():
23
+ if reasoning.strip():
24
+ return
25
+ warning = self.agent.read_prompt("fw.msg_empty_response.md")
26
+ elif response != self.agent.loop_data.last_response:
27
+ return
28
+ else:
29
+ warning = self.agent.read_prompt("fw.msg_repeat.md")
30
+
31
+ log_item = self.agent.loop_data.params_temporary.get("log_item_generating")
32
+ assistant_message = self.agent.hist_add_ai_response(
33
+ response,
34
+ id=log_item.id if log_item else "",
35
+ llm_result=llm_result,
36
+ )
37
+ self.agent._remember_llm_result_state(llm_result, assistant_message)
38
+ warning_message = self.agent.hist_add_warning(message=warning)
39
+ PrintStyle(font_color="orange", padding=True).print(warning)
40
+ self.agent.context.log.log(
41
+ type="warning", content=warning, id=warning_message.id
42
+ )
43
+ result_data["skip_default_processing"] = True
prompts/fw.msg_empty_response.md
new
+1
@@ -0,0 +1 @@
1
+Model returned an empty response (no reasoning, no content).
tests/test_message_loop_result.py
new
+63
@@ -0,0 +1,63 @@
1
+from types import SimpleNamespace
2
+
3
+from extensions.python.message_loop_result._20_loop_control import LoopControl
4
+
5
+
6
+class FakeAgent:
7
+ def __init__(self, response: str, reasoning: str = "", last_response: str = ""):
8
+ self.loop_data = SimpleNamespace(
9
+ last_response=last_response,
10
+ params_temporary={},
11
+ )
12
+ self.context = SimpleNamespace(log=SimpleNamespace(log=lambda **kwargs: None))
13
+ self.response = response
14
+ self.reasoning = reasoning
15
+ self.warnings = []
16
+ self.history = []
17
+
18
+ def read_prompt(self, name):
19
+ return {
20
+ "fw.msg_empty_response.md": "empty",
21
+ "fw.msg_repeat.md": "repeat",
22
+ }[name]
23
+
24
+ def hist_add_ai_response(self, response, **kwargs):
25
+ self.history.append(response)
26
+ return SimpleNamespace(id="assistant")
27
+
28
+ def _remember_llm_result_state(self, *args):
29
+ pass
30
+
31
+ def hist_add_warning(self, message):
32
+ self.warnings.append(message)
33
+ return SimpleNamespace(id="warning")
34
+
35
+
36
+def _run(agent):
37
+ result_data = {
38
+ "llm_result": SimpleNamespace(response=agent.response, reasoning=agent.reasoning)
39
+ }
40
+ LoopControl(agent).execute(result_data)
41
+ return result_data
42
+
43
+
44
+def test_empty_result_skips_default_processing():
45
+ agent = FakeAgent("")
46
+
47
+ assert _run(agent)["skip_default_processing"] is True
48
+ assert agent.history == [""]
49
+ assert agent.warnings == ["empty"]
50
+
51
+
52
+def test_repeat_skips_default_processing():
53
+ agent = FakeAgent('{"tool_name":"response"}', last_response='{"tool_name":"response"}')
54
+
55
+ assert _run(agent)["skip_default_processing"] is True
56
+ assert agent.warnings == ["repeat"]
57
+
58
+
59
+def test_result_with_reasoning_uses_default_processing():
60
+ agent = FakeAgent("", reasoning="thinking")
61
+
62
+ assert "skip_default_processing" not in _run(agent)
63
+ assert agent.history == []
tests/test_unusable_response_loop.py
+2
-1
@@ -21,6 +21,7 @@ def _agent():
21
prompts = {
22
"fw.msg_misformat.md": "misformatted",
23
"fw.msg_repeat.md": "repeated",
24
+ "fw.msg_empty_response.md": "empty response",
25
}
26
27
def read_prompt(name, **kwargs):
@@ -53,7 +54,7 @@ def test_stops_at_configured_failure_limit(monkeypatch):
54
assert _run(extension, agent, "misformatted")["exception"] is None
55
56
agent.loop_data.iteration = 1
56
- assert _run(extension, agent, "repeated")["exception"] is None
57
+ assert _run(extension, agent, "empty response")["exception"] is None
58
59
agent.loop_data.iteration = 2
60
data = _run(extension, agent, "repeated")