fix: ensure visible response after tool runs (#4033)

* fix: ensure visible response after tool runs

* fix: clean up terminal response recovery state

* fix: bound terminal recovery state
This commit is contained in:
Huixin615
2026-07-11 15:53:14 +08:00
committed by GitHub
parent aafd5077b2
commit 6389fc03fd
6 changed files with 520 additions and 11 deletions
+2
View File
@@ -739,6 +739,8 @@ This is the difference between a chatbot with tool access and an agent with an a
**Strict Tool-Call Recovery**: When a provider or middleware interrupts a tool-call loop, DeerFlow now strips provider-level raw tool-call metadata on forced-stop assistant messages and injects placeholder tool results for dangling calls before the next model invocation. This keeps OpenAI-compatible reasoning models that strictly validate `tool_call_id` sequences from failing with malformed history errors.
**Visible Tool-Run Completion**: For interactive turns, DeerFlow retries an empty post-tool final response once, then surfaces a visible error instead of reporting a silent successful run.
### Long-Term Memory
Most agents forget everything the moment a conversation ends. DeerFlow remembers.
+4 -3
View File
@@ -249,9 +249,10 @@ Lead-agent middlewares are assembled in strict order across three functions: the
26. **SubagentLimitMiddleware** - *(optional, if `subagent_enabled`)* Truncates excess `task` tool calls to enforce the `MAX_CONCURRENT_SUBAGENTS` limit
27. **LoopDetectionMiddleware** - *(optional, if `loop_detection.enabled`)* Detects repeated tool-call loops; hard-stop clears both structured `tool_calls` and raw provider tool-call metadata before forcing a final text answer; stamps `loop_capped` via `consume_stop_reason` (#3875 Phase 2), symmetric to `TokenBudgetMiddleware`
28. **TokenBudgetMiddleware** - *(optional, if `token_budget.enabled`)* Enforces per-run token limits
29. **Custom middlewares** - *(optional)* Any `custom_middlewares` passed to `build_middlewares` are injected here, before the safety/clarification tail
30. **SafetyFinishReasonMiddleware** - *(optional, if `safety_finish_reason.enabled`)* Suppresses tool execution when the provider safety-terminated the response (e.g. `finish_reason=content_filter`); registered after custom middlewares so LangChain's reverse-order `after_model` dispatch runs it first
31. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, writes a readable `ToolMessage.content` fallback plus structured `ToolMessage.artifact.human_input` request payload, and interrupts via `Command(goto=END)` (must be last). Because this middleware can short-circuit tool execution before LangChain emits `on_tool_end`, `RunJournal` performs a root-run final reconciliation for allowlisted clarification `ToolMessage`s whose `tool_call_id` was produced by the current run, so human-input request cards remain recoverable from `run_events` after checkpoint compaction. Human Input Card replies are submitted as `hide_from_ui` `HumanMessage`s with `additional_kwargs.human_input_response`; `RunJournal` persists only allowlisted hidden response sources (currently `ask_clarification`) as `llm.human.input`, which preserves answered-card state after compaction without exposing generic internal hidden context.
29. **Custom middlewares** - *(optional)* Any `custom_middlewares` passed to `build_middlewares` are injected here, before the terminal-response/safety/clarification tail
30. **TerminalResponseMiddleware** - When a provider returns an empty terminal `AIMessage` after tool execution, injects a hidden recovery prompt and retries the model once; a second empty response is replaced in checkpoint state by a visible error fallback marked for the run worker, so the run finishes as an error instead of a silent success
31. **SafetyFinishReasonMiddleware** - *(optional, if `safety_finish_reason.enabled`)* Suppresses tool execution when the provider safety-terminated the response (e.g. `finish_reason=content_filter`); registered after terminal-response/custom middlewares so LangChain's reverse-order `after_model` dispatch runs it first
32. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, writes a readable `ToolMessage.content` fallback plus structured `ToolMessage.artifact.human_input` request payload, and interrupts via `Command(goto=END)` (must be last). Because this middleware can short-circuit tool execution before LangChain emits `on_tool_end`, `RunJournal` performs a root-run final reconciliation for allowlisted clarification `ToolMessage`s whose `tool_call_id` was produced by the current run, so human-input request cards remain recoverable from `run_events` after checkpoint compaction. Human Input Card replies are submitted as `hide_from_ui` `HumanMessage`s with `additional_kwargs.human_input_response`; `RunJournal` persists only allowlisted hidden response sources (currently `ask_clarification`) as `llm.human.input`, which preserves answered-card state after compaction without exposing generic internal hidden context.
### Configuration System
@@ -33,6 +33,7 @@ from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
from deerflow.agents.middlewares.safety_finish_reason_middleware import SafetyFinishReasonMiddleware
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
from deerflow.agents.middlewares.summarization_middleware import DeerFlowSummarizationMiddleware, create_summarization_middleware
from deerflow.agents.middlewares.terminal_response_middleware import TerminalResponseMiddleware
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
from deerflow.agents.middlewares.todo_middleware import TodoMiddleware
from deerflow.agents.middlewares.token_usage_middleware import TokenUsageMiddleware
@@ -369,11 +370,16 @@ def build_middlewares(
if custom_middlewares:
middlewares.extend(custom_middlewares)
# A provider may return an empty AIMessage after tool execution. Retry the
# final response once, then persist a visible error fallback rather than
# allowing LangChain's no-tool-call router to end a silent successful run.
middlewares.append(TerminalResponseMiddleware())
# SafetyFinishReasonMiddleware — suppress tool execution when the provider
# safety-terminated the response. Registered after custom middlewares so
# that LangChain's reverse-order after_model dispatch runs Safety first;
# cleared tool_calls then flow through Loop/Subagent accounting without
# firing extra alarms. See safety_finish_reason_middleware.py docstring.
# safety-terminated the response. Registered after the terminal-response
# and custom middlewares so LangChain's reverse-order after_model dispatch
# runs Safety first; cleared tool_calls then flow through the remaining
# accounting/terminal guards without firing extra alarms.
safety_config = resolved_app_config.safety_finish_reason
if safety_config.enabled:
middlewares.append(SafetyFinishReasonMiddleware.from_config(safety_config))
@@ -0,0 +1,214 @@
"""Ensure tool-using lead-agent turns end with a visible assistant response."""
from __future__ import annotations
import threading
from collections.abc import Awaitable, Callable
from typing import Any, override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse, hook_config
from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage, ToolMessage
from langgraph.runtime import Runtime
from deerflow.agents.middlewares._bounded_dict import BoundedDict
_RECOVERY_PROMPT = (
"<system_reminder>\n"
"Your previous response after the tool execution was empty. Review the tool results "
"already present in the conversation and provide a concise, user-visible final response. "
"Do not call another tool unless it is strictly necessary.\n"
"</system_reminder>"
)
_FALLBACK_CONTENT = "The model completed the tool run but returned no final response, including after one automatic retry. Please try again or use a different model."
_TOOL_CALL_FINISH_REASONS = {"tool_calls", "function_call"}
def _has_visible_content(message: AIMessage) -> bool:
"""Return whether an AI message contains user-visible text."""
content = message.content
if isinstance(content, str):
return bool(content.strip())
if isinstance(content, list):
for block in content:
if isinstance(block, str) and block.strip():
return True
if isinstance(block, dict) and block.get("type") in {"text", "output_text"}:
text = block.get("text")
if isinstance(text, str) and text.strip():
return True
return False
def _has_tool_call_intent_or_error(message: AIMessage) -> bool:
"""Keep tool routing and malformed tool-call handling out of this guard."""
if message.tool_calls or getattr(message, "invalid_tool_calls", None):
return True
additional_kwargs = message.additional_kwargs or {}
if additional_kwargs.get("tool_calls") or additional_kwargs.get("function_call"):
return True
response_metadata = message.response_metadata or {}
return response_metadata.get("finish_reason") in _TOOL_CALL_FINISH_REASONS
def _tool_result_in_current_turn(messages: list[Any]) -> bool:
"""Return whether a tool result follows the latest real user message."""
latest_user_index = -1
for index, message in enumerate(messages):
if not isinstance(message, HumanMessage):
continue
if (message.additional_kwargs or {}).get("hide_from_ui"):
continue
latest_user_index = index
# Scope: #4027 covers interactive post-tool turns. Scheduled/internal
# invocations without a real HumanMessage need a separate terminal-success
# invariant rather than being inferred from arbitrary historical tools.
if latest_user_index == -1:
return False
return any(isinstance(message, ToolMessage) for message in messages[latest_user_index + 1 :])
class TerminalResponseMiddleware(AgentMiddleware[AgentState]):
"""Retry one empty post-tool response, then persist a visible error fallback."""
def __init__(self) -> None:
super().__init__()
self._lock = threading.Lock()
self._retry_counts: BoundedDict[tuple[str, str], int] = BoundedDict(1000)
self._pending_prompts: BoundedDict[tuple[str, str], bool] = BoundedDict(1000)
@staticmethod
def _key(runtime: Runtime) -> tuple[str, str]:
context = getattr(runtime, "context", None)
if isinstance(context, dict):
thread_id = str(context.get("thread_id") or "unknown-thread")
run_id = str(context.get("run_id") or context.get("run_attempt_id") or id(runtime))
return thread_id, run_id
# Defensive fallback for tests/custom embeddings. Production Gateway
# runs always provide thread_id and run_id in Runtime.context.
return "unknown-thread", str(id(runtime))
def _clear(self, runtime: Runtime) -> None:
key = self._key(runtime)
with self._lock:
self._retry_counts.pop(key, None)
self._pending_prompts.pop(key, None)
def _clear_other_runs(self, runtime: Runtime) -> None:
thread_id, run_id = self._key(runtime)
with self._lock:
stale = [key for key in self._retry_counts if key[0] == thread_id and key[1] != run_id]
for key in stale:
self._retry_counts.pop(key, None)
self._pending_prompts.pop(key, None)
def _apply(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
messages = list(state.get("messages") or [])
if not messages or not isinstance(messages[-1], AIMessage):
return None
last = messages[-1]
if _has_visible_content(last) or _has_tool_call_intent_or_error(last):
return None
if not _tool_result_in_current_turn(messages):
return None
key = self._key(runtime)
with self._lock:
# The recovery budget is once per run, not once per empty message.
# A retry that calls another tool must not refresh the budget and
# create an unbounded empty -> retry -> tool loop.
retry_count = self._retry_counts.get(key, 0)
if retry_count == 0:
self._retry_counts[key] = 1
self._pending_prompts[key] = True
if retry_count == 0:
# The next model call gets a new message id. Remove this empty
# terminal message now so a successful recovery does not leave it
# in checkpoint history or future model context.
message_updates = [RemoveMessage(id=last.id)] if last.id else []
return {"messages": message_updates, "jump_to": "model"}
additional_kwargs = dict(last.additional_kwargs or {})
additional_kwargs.update(
{
"deerflow_error_fallback": True,
"error_reason": "Model returned an empty terminal response after one retry",
}
)
fallback = last.model_copy(
update={
"content": _FALLBACK_CONTENT,
"additional_kwargs": additional_kwargs,
}
)
return {"messages": [fallback]}
def _augment_request(self, request: ModelRequest) -> ModelRequest:
key = self._key(request.runtime)
with self._lock:
pending = key in self._pending_prompts
self._pending_prompts.pop(key, None)
if not pending:
return request
reminder = HumanMessage(
content=_RECOVERY_PROMPT,
name="terminal_response_recovery",
additional_kwargs={"hide_from_ui": True},
)
return request.override(messages=[*request.messages, reminder])
@override
def before_agent(self, state: AgentState, runtime: Runtime) -> dict | None:
self._clear_other_runs(runtime)
# A prior invocation can bypass after_agent via Command(goto=END).
# Reset the same run id here so resume starts with a fresh one-retry
# budget; internal jump_to=model loops do not re-run before_agent.
self._clear(runtime)
return None
@override
async def abefore_agent(self, state: AgentState, runtime: Runtime) -> dict | None:
self._clear_other_runs(runtime)
self._clear(runtime)
return None
@hook_config(can_jump_to=["model"])
@override
def after_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
return self._apply(state, runtime)
@hook_config(can_jump_to=["model"])
@override
async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
return self._apply(state, runtime)
@override
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelCallResult:
return handler(self._augment_request(request))
@override
async def awrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelCallResult:
return await handler(self._augment_request(request))
@override
def after_agent(self, state: AgentState, runtime: Runtime) -> dict | None:
self._clear(runtime)
return None
@override
async def aafter_agent(self, state: AgentState, runtime: Runtime) -> dict | None:
self._clear(runtime)
return None
@@ -372,10 +372,18 @@ def test_build_middlewares_uses_resolved_model_name_for_vision(monkeypatch):
assert any(isinstance(m, lead_agent_module.ViewImageMiddleware) for m in middlewares)
# verify the custom middleware is injected correctly.
# Chain tail order after the custom middleware is:
# ..., custom, SafetyFinishReasonMiddleware, ClarificationMiddleware
# so the custom mock sits at index [-3].
assert len(middlewares) > 0 and isinstance(middlewares[-3], MagicMock)
# With this test's default safety config enabled, the tail order is:
# ..., custom, TerminalResponseMiddleware, SafetyFinishReasonMiddleware,
# ClarificationMiddleware, so the custom mock sits at index [-4].
assert len(middlewares) > 0 and isinstance(middlewares[-4], MagicMock)
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
from deerflow.agents.middlewares.safety_finish_reason_middleware import SafetyFinishReasonMiddleware
from deerflow.agents.middlewares.terminal_response_middleware import TerminalResponseMiddleware
assert isinstance(middlewares[-3], TerminalResponseMiddleware)
assert isinstance(middlewares[-2], SafetyFinishReasonMiddleware)
assert isinstance(middlewares[-1], ClarificationMiddleware)
def test_build_middlewares_passes_explicit_app_config_to_shared_factory(monkeypatch):
@@ -0,0 +1,278 @@
from __future__ import annotations
from typing import Any
import pytest
from langchain.agents import create_agent
from langchain_core.language_models import BaseChatModel
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langchain_core.outputs import ChatGeneration, ChatResult
from langchain_core.tools import tool
from deerflow.agents.middlewares.terminal_response_middleware import TerminalResponseMiddleware
from deerflow.runtime.runs.worker import _extract_llm_error_fallback_message
@tool
def lookup_status() -> str:
"""Return a deterministic tool result."""
return "tool completed"
class _PostToolResponseModel(BaseChatModel):
responses: list[str]
call_count: int = 0
observed_messages: list[list[Any]] = []
@property
def _llm_type(self) -> str:
return "post-tool-response"
def bind_tools(self, tools, **kwargs):
return self
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
self.observed_messages.append(list(messages))
self.call_count += 1
if self.call_count == 1:
message = AIMessage(
content="",
tool_calls=[{"id": "call-1", "name": "lookup_status", "args": {}}],
response_metadata={"finish_reason": "tool_calls"},
)
else:
message = AIMessage(
content=self.responses[self.call_count - 2],
response_metadata={"finish_reason": "stop"},
)
return ChatResult(generations=[ChatGeneration(message=message)])
async def _agenerate(self, messages, stop=None, run_manager=None, **kwargs):
return self._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
class _PerRunRetryBudgetModel(BaseChatModel):
call_count: int = 0
observed_messages: list[list[Any]] = []
@property
def _llm_type(self) -> str:
return "per-run-retry-budget"
def bind_tools(self, tools, **kwargs):
return self
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
self.observed_messages.append(list(messages))
self.call_count += 1
if self.call_count == 1:
message = AIMessage(
content="",
tool_calls=[{"id": "call-budget-1", "name": "lookup_status", "args": {}}],
response_metadata={"finish_reason": "tool_calls"},
)
elif self.call_count == 2:
message = AIMessage(content="", response_metadata={"finish_reason": "stop"})
elif self.call_count == 3:
message = AIMessage(
content="I need one more status check.",
tool_calls=[{"id": "call-budget-2", "name": "lookup_status", "args": {}}],
response_metadata={"finish_reason": "tool_calls"},
)
else:
message = AIMessage(content="", response_metadata={"finish_reason": "stop"})
return ChatResult(generations=[ChatGeneration(message=message)])
async def _agenerate(self, messages, stop=None, run_manager=None, **kwargs):
return self._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
def _agent(model: BaseChatModel):
return create_agent(
model=model,
tools=[lookup_status],
middleware=[TerminalResponseMiddleware()],
)
def _empty_terminal_messages(messages: list[Any]) -> list[AIMessage]:
return [message for message in messages if isinstance(message, AIMessage) and not message.tool_calls and not message.invalid_tool_calls and not str(message.content).strip()]
def test_retries_empty_post_tool_response_once_and_returns_model_answer():
model = _PostToolResponseModel(responses=["", "The tool completed successfully."])
result = _agent(model).invoke(
{"messages": [HumanMessage(content="Check the status")]},
context={"thread_id": "thread-1", "run_id": "run-1"},
)
assert model.call_count == 3
final = result["messages"][-1]
assert isinstance(final, AIMessage)
assert final.content == "The tool completed successfully."
assert _empty_terminal_messages(result["messages"]) == []
assert any(isinstance(message, HumanMessage) and message.name == "terminal_response_recovery" and message.additional_kwargs.get("hide_from_ui") is True for message in model.observed_messages[-1])
assert not any(isinstance(message, HumanMessage) and message.name == "terminal_response_recovery" for message in result["messages"])
def test_second_empty_post_tool_response_becomes_visible_error_fallback():
model = _PostToolResponseModel(responses=["", ""])
result = _agent(model).invoke(
{"messages": [HumanMessage(content="Check the status")]},
context={"thread_id": "thread-2", "run_id": "run-2"},
)
assert model.call_count == 3
final = result["messages"][-1]
assert isinstance(final, AIMessage)
assert "returned no final response" in str(final.content)
assert final.additional_kwargs["deerflow_error_fallback"] is True
assert _empty_terminal_messages(result["messages"]) == []
assert _extract_llm_error_fallback_message(result) == ("Model returned an empty terminal response after one retry")
@pytest.mark.asyncio
async def test_async_graph_retries_empty_post_tool_response_once():
model = _PostToolResponseModel(responses=["", "Recovered asynchronously."])
result = await _agent(model).ainvoke(
{"messages": [HumanMessage(content="Check the status")]},
context={"thread_id": "thread-async", "run_id": "run-async"},
)
assert model.call_count == 3
assert result["messages"][-1].content == "Recovered asynchronously."
assert _empty_terminal_messages(result["messages"]) == []
def test_graph_with_thread_id_only_keeps_recovery_state_across_model_loop():
model = _PostToolResponseModel(responses=["", "Recovered without a run id."])
result = _agent(model).invoke(
{"messages": [HumanMessage(content="Check the status")]},
context={"thread_id": "thread-only"},
)
assert model.call_count == 3
assert result["messages"][-1].content == "Recovered without a run id."
assert _empty_terminal_messages(result["messages"]) == []
def test_recovery_budget_is_once_per_run_even_when_retry_calls_another_tool():
model = _PerRunRetryBudgetModel()
result = _agent(model).invoke(
{"messages": [HumanMessage(content="Check the status twice")]},
context={"thread_id": "thread-budget", "run_id": "run-budget"},
)
assert model.call_count == 4
final = result["messages"][-1]
assert final.additional_kwargs["deerflow_error_fallback"] is True
assert _empty_terminal_messages(result["messages"]) == []
recovery_prompt_count = sum(1 for request_messages in model.observed_messages for message in request_messages if isinstance(message, HumanMessage) and message.name == "terminal_response_recovery")
assert recovery_prompt_count == 1
def test_empty_response_without_tool_result_is_not_retried():
middleware = TerminalResponseMiddleware()
message = AIMessage(content="", response_metadata={"finish_reason": "stop"})
state = {"messages": [HumanMessage(content="Hello"), message]}
runtime = type("RuntimeStub", (), {"context": {"thread_id": "thread-3", "run_id": "run-3"}})()
assert middleware.after_model(state, runtime) is None
def test_tool_call_intent_is_not_treated_as_empty_terminal_response():
middleware = TerminalResponseMiddleware()
message = AIMessage(
content="",
tool_calls=[{"id": "call-2", "name": "lookup_status", "args": {}}],
response_metadata={"finish_reason": "tool_calls"},
)
state = {"messages": [HumanMessage(content="Hello"), message]}
runtime = type("RuntimeStub", (), {"context": {"thread_id": "thread-4", "run_id": "run-4"}})()
assert middleware.after_model(state, runtime) is None
@pytest.mark.parametrize(
"message",
[
AIMessage(content="", invalid_tool_calls=[{"id": "bad-1", "name": "lookup_status", "args": "{"}]),
AIMessage(content="", additional_kwargs={"function_call": {"name": "lookup_status", "arguments": "{}"}}),
AIMessage(content="", response_metadata={"finish_reason": "function_call"}),
],
)
def test_invalid_or_legacy_tool_call_intent_is_not_treated_as_empty_terminal_response(message):
middleware = TerminalResponseMiddleware()
state = {"messages": [HumanMessage(content="Hello"), message]}
runtime = type("RuntimeStub", (), {"context": {"thread_id": "thread-5", "run_id": "run-5"}})()
assert middleware.after_model(state, runtime) is None
def test_after_agent_clears_retry_state_for_the_run():
middleware = TerminalResponseMiddleware()
runtime = type("RuntimeStub", (), {"context": {"thread_id": "thread-6", "run_id": "run-6"}})()
empty_after_tool = {
"messages": [
HumanMessage(content="Check the status"),
ToolMessage(content="tool completed", tool_call_id="call-6"),
AIMessage(content="", response_metadata={"finish_reason": "stop"}),
]
}
first = middleware.after_model(empty_after_tool, runtime)
assert first is not None and first["jump_to"] == "model"
middleware.after_agent(empty_after_tool, runtime)
second = middleware.after_model(empty_after_tool, runtime)
assert second is not None and second["jump_to"] == "model"
def test_before_agent_clears_same_run_state_for_resumed_invocation():
middleware = TerminalResponseMiddleware()
runtime = type("RuntimeStub", (), {"context": {"thread_id": "thread-7", "run_id": "run-7"}})()
empty_after_tool = {
"messages": [
HumanMessage(content="Check the status"),
ToolMessage(content="tool completed", tool_call_id="call-7"),
AIMessage(content="", response_metadata={"finish_reason": "stop"}),
]
}
first = middleware.after_model(empty_after_tool, runtime)
assert first is not None and first["jump_to"] == "model"
middleware.before_agent(empty_after_tool, runtime)
resumed = middleware.after_model(empty_after_tool, runtime)
assert resumed is not None and resumed["jump_to"] == "model"
def test_tool_history_without_real_user_message_does_not_trigger_recovery():
middleware = TerminalResponseMiddleware()
runtime = type("RuntimeStub", (), {"context": {"thread_id": "thread-8", "run_id": "run-8"}})()
state = {
"messages": [
HumanMessage(content="internal", additional_kwargs={"hide_from_ui": True}),
ToolMessage(content="tool completed", tool_call_id="call-8"),
AIMessage(content="", response_metadata={"finish_reason": "stop"}),
]
}
assert middleware.after_model(state, runtime) is None
def test_abandoned_run_state_is_bounded():
middleware = TerminalResponseMiddleware()
for index in range(1001):
key = (f"thread-{index}", f"run-{index}")
middleware._retry_counts[key] = 1
middleware._pending_prompts[key] = True
assert len(middleware._retry_counts) == 1000
assert len(middleware._pending_prompts) == 1000
assert ("thread-0", "run-0") not in middleware._retry_counts
assert ("thread-0", "run-0") not in middleware._pending_prompts