Merge PR #1600: text-only grounding corrections
This commit is contained in:
commit
a366ccdd11
2 files changed
+124
-2
No files matched your search
+48
-2
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user