Merge PR #1600: text-only grounding corrections

This commit is contained in:
Haozhe Wu committed 2026-09-28 16:19:43 +08:00
commit a366ccdd11
2 files changed
+124 -2

No files matched your search

+48 -2
View File
@@ -1353,6 +1353,10 @@ class AgentLoop:
empty_model_response_iter: int | None = None
consecutive_empty_responses = 0
grounding_revisions = 0
# A normal grounding correction is a text revision, not a new research
# turn. Explicit grounding recovery (identity / missing price evidence)
# keeps tools available; ordinary correction turns do not.
grounding_correction_text_only = False
llm_usage_summary = _new_llm_usage_summary(self.llm)
last_response_model: str | None = None
goal_continuations = 0
@@ -1536,11 +1540,26 @@ class AgentLoop:
reasoning_event["tail"] = reasoning_tail
self._emit("reasoning_delta", reasoning_event)
# On last iteration, drop tool definitions to force text output
# The final iteration is always text-only. A grounding correction
# whose validator requested no explicit recovery is text-only too:
# the model must revise from evidence already gathered instead of
# starting another research/refetch loop merely to reformat a draft.
is_last_iteration = (iteration == self.max_iterations)
tool_defs = None if is_last_iteration else self.registry.get_definitions()
correction_text_only = grounding_correction_text_only
tool_defs = (
None
if is_last_iteration or correction_text_only
else self.registry.get_definitions()
)
if is_last_iteration:
trace.write({"type": "forced_text_only", "iter": current_iter})
elif correction_text_only:
trace.write(
{
"type": "grounding_correction_text_only",
"iter": current_iter,
}
)
_llm_timeout_s = _llm_timeout_seconds()
llm_timeout = _llm_timeout_s if _llm_timeout_s > 0 else None
@@ -1713,6 +1732,29 @@ class AgentLoop:
# Not filtered — reset the consecutive-skip counter.
consecutive_content_filter_count = 0
if correction_text_only and response.has_tool_calls:
# Tools were deliberately not offered on this correction turn.
# Defensive handling keeps a provider/model that nevertheless
# emits a tool call from escaping the bounded revision path.
trace.write(
{
"type": "grounding_correction_tool_call_blocked",
"iter": current_iter,
}
)
messages.append(
{
"role": "system",
"content": (
"[SYSTEM] This is a grounding correction turn. "
"Do not call tools. Revise the previous draft using "
"the evidence already gathered, or remove claims "
"that cannot be supported."
),
}
)
continue
if not response.has_tool_calls:
final_content = response.content or ""
syntax_fallback_emitted = False
@@ -1853,6 +1895,9 @@ class AgentLoop:
)
recovery = self._grounding.recovery_action(validation)
if recovery is not None and iteration < self.max_iterations:
# Explicit bounded recovery is the one case where
# the next turn is allowed to research again.
grounding_correction_text_only = False
self._grounding.record_recovery(recovery)
trace.write(
{
@@ -1900,6 +1945,7 @@ class AgentLoop:
iteration < self.max_iterations
and grounding_revisions < MAX_GROUNDING_REVISIONS
):
grounding_correction_text_only = True
self._emit(
"grounding_status",
{
+76
View File
@@ -621,6 +621,7 @@ class _ScriptedLLM:
self.responses = list(responses)
self.calls = 0
self.messages_history: list[list[dict[str, Any]]] = []
self.tools_history: list[list[Any] | None] = []
def stream_chat(
self,
@@ -634,6 +635,7 @@ class _ScriptedLLM:
) -> _Response:
self.calls += 1
self.messages_history.append(list(messages))
self.tools_history.append(tools)
response = self.responses.pop(0) if len(self.responses) > 1 else self.responses[0]
if response.content and on_text_chunk:
on_text_chunk(response.content)
@@ -722,6 +724,80 @@ def test_loop_releases_the_redacted_draft_instead_of_the_canned_refusal(tmp_path
assert_system_messages_only_lead(llm.messages_history)
def test_grounding_revision_turn_is_text_only(tmp_path: Path) -> None:
"""A rejected numeric draft is revised without opening another research loop."""
rejected = (
"562500.SS(Yahoo,CNY)最新收盘价 1.171 元。"
"建议买入价为 0.881。"
)
corrected = "562500.SS(Yahoo,CNY)最新收盘价 1.171 元。"
llm = _ScriptedLLM(
_SCRIPT_HEAD + [_Response(content=rejected), _Response(content=corrected)]
)
result, _events, _agent = _run(tmp_path, llm, max_iterations=8)
assert result["status"] == "success"
assert llm.calls == len(_SCRIPT_HEAD) + 2
# The original drafting turn still has the registry. Once grounding rejects
# it without requesting explicit recovery, the next turn receives no tools.
assert llm.tools_history[len(_SCRIPT_HEAD)] is not None
assert llm.tools_history[len(_SCRIPT_HEAD) + 1] is None
trace = TraceWriter.read(tmp_path / "run")
assert [
event
for event in trace
if event.get("type") == "grounding_correction_text_only"
]
def test_grounding_revision_blocks_unoffered_tool_calls(tmp_path: Path) -> None:
"""A provider cannot escape correction-only mode by emitting a tool call anyway."""
rejected = (
"562500.SS(Yahoo,CNY)最新收盘价 1.171 元。"
"建议买入价为 0.881。"
)
corrected = "562500.SS(Yahoo,CNY)最新收盘价 1.171 元。"
llm = _ScriptedLLM(
_SCRIPT_HEAD
+ [
_Response(content=rejected),
_Response(
tool_calls=[
_tool_call(
"refetch",
"get_market_data",
codes=[SYMBOL],
start_date="2026-06-23",
end_date="2026-06-24",
source="auto",
)
]
),
_Response(content=corrected),
]
)
result, _events, _agent = _run(tmp_path, llm, max_iterations=8)
assert result["status"] == "success"
trace = TraceWriter.read(tmp_path / "run")
assert [
event
for event in trace
if event.get("type") == "grounding_correction_tool_call_blocked"
]
# The attempted recovery call was never executed/recorded as a tool result.
assert not [
event
for event in trace
if event.get("type") == "tool_result" and event.get("call_id") == "refetch"
]
# Correction-only survives the blocked attempt and remains text-only.
assert llm.tools_history[len(_SCRIPT_HEAD) + 1] is None
assert llm.tools_history[len(_SCRIPT_HEAD) + 2] is None
def test_loop_repairs_a_missing_source_word_without_another_model_round(tmp_path: Path) -> None:
missing_source = "562500.SS 在 2026-06-23 的已观测收盘价为 1.137 CNY,均线空头排列。"
llm = _ScriptedLLM(