fix(goal): retain supplied evidence and retry one malformed evaluator verdict (#7345)

* fix(goal): use spare evidence budget for long user input

* fix(goal): retry one malformed evaluator verdict

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
This commit is contained in:
Totoro 2026-10-23 20:29:35 +08:00 committed by GitHub
parent 4e20cbb4d6
commit fc4f719664
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 177 additions and 29 deletions

View File

@ -2268,6 +2268,9 @@ Supported commands:
After each Gateway-backed run, DeerFlow evaluates the visible conversation, including the assistant's tool calls and shortened tool results, against the active goal with a non-thinking evaluator model. A successful tool result alone does not satisfy a goal, and when the assistant had to guess missing or ambiguous information the evaluator reports `needs_user_input`. The evaluator must return a typed blocker (`missing_evidence`, `needs_user_input`, `run_failed`, `external_wait`, or `goal_not_met_yet`) plus visible evidence. DeerFlow only injects a hidden continuation when the latest assistant turn is durably checkpointed, the blocker is `goal_not_met_yet`, the thread did not change during evaluation, and the no-progress breaker has not fired. The safety cap defaults to 8 hidden continuations, and repeated identical non-progress evaluations stop after 2 attempts. `/goal clear` and any user-authored new input win over queued continuations. When the evaluator considers the goal satisfied, DeerFlow clears it after the run successfully finalizes and publishes the updated thread state. The same checkpoint records the completion as `goal_outcome`, which stays until the next goal change. If required artifact delivery or its receipt persistence fails, the run reports an error and the goal remains active for a later retry; this does not start another hidden continuation.
The evaluator keeps recent work and the latest user request within a fixed evidence budget, using spare room to retain more of long user input.
If a normally completed evaluator response has an invalid verdict format, DeerFlow evaluates the same evidence once more unless you cancel or change the thread. Both calls are counted; a second failure stops automatic continuation without repeating the task or its tools.
For tool-using turns, successful operation results drive the no-progress count: changing only reply wording or a sandbox tool's UI explanation does not reset it, while newly observed work can keep it going. Text-only turns keep their reply-based check. An unmet goal still stops automatic continuation after a token or loop-detection hard stop and stays active for a later user turn.
Regenerating an earlier response keeps the goal currently set for the chat and its used continuation budget. It does not restore a goal that was cleared or achieved. The earlier answer's completion record is removed when its historical checkpoint is replayed.

View File

@ -363,6 +363,16 @@ so recovery need not inspect mutable thread state. All non-interactive policies
allow disclosed, low-risk reversible assumptions only with achievement evidence;
their evaluator responses must explicitly provide the assumption boolean.
Goal evidence stays within 12,000 characters. Keep the selected recent work and
omission labels intact; use remaining space to restore shortened user input
rather than treating the 2,000-character request reservation as a hard cap.
Only a malformed verdict from an explicitly normal model completion may retry,
once, with the original evidence. Recheck cancellation, Goal identity and
checkpoint/messages first. Each complete evaluator invocation keeps its own usage
ID and observer call; one final decision updates Goal counters. Valid verdicts,
length/safety stops, and transport/configuration errors are not format retries.
Goal evaluator usage crosses a JSON-safe sink into
`RunJournal.record_external_llm_usage_records` before response parsing or observer
notification can fail. Critic tokens/cache reads and distinct calls belong to

View File

@ -76,6 +76,10 @@ class GoalWriteConflict(RuntimeError):
"""Raised when a goal write is based on a stale checkpoint."""
class GoalEvaluationFormatError(ValueError):
"""A normally completed evaluator response did not contain a valid verdict."""
def goal_thread_lock(thread_id: str) -> AbstractAsyncContextManager[None]:
"""Serialize goal read-modify-write sequences within the current event loop."""
return _goal_locks.hold(thread_id)
@ -306,7 +310,8 @@ def _cap_evidence(lines: list[str]) -> str:
that work answers, and so is the latest Human Input Card answer left out. Each is shortened
only when it and the lines after it do not fit whole. If the next assistant message back
does not fit whole, its end fills the room left. A marker says how many lines were left
out. No line starts midway without a label.
out. Spare room then restores shortened user input without displacing the selected
recent work. No line starts midway without a label.
"""
conversation = "\n\n".join(lines)
if len(conversation) <= MAX_GOAL_CONVERSATION_CHARS:
@ -345,6 +350,13 @@ def _cap_evidence(lines: list[str]) -> str:
start = boundary
omitted = start - len(head)
marker = [f"[{omitted} earlier evidence lines omitted]"] if omitted else []
spare = MAX_GOAL_CONVERSATION_CHARS - len("\n\n".join([*head, *marker, *kept]))
for offset, index in enumerate(picks if head else []):
if spare <= 0:
break
expanded = _truncate(lines[index], MAX_GOAL_REQUEST_CHARS + spare)
spare += len(head[offset]) - len(expanded)
head[offset] = expanded
return "\n\n".join([*head, *marker, *kept])
@ -547,7 +559,20 @@ async def evaluate_goal_completion(
invoke=invoke_with_usage,
task_store=task_store,
)
return parse_goal_evaluation_response(_extract_response_text(response.content), require_assumption_attribution=interaction_policy is not None and not interaction_policy.allows_clarification)
try:
return parse_goal_evaluation_response(_extract_response_text(response.content), require_assumption_attribution=interaction_policy is not None and not interaction_policy.allows_clarification)
except ValueError as exc:
metadata = getattr(response, "response_metadata", None)
reasons = [metadata[key] for key in ("finish_reason", "stop_reason") if key in metadata] if isinstance(metadata, Mapping) else []
blocks = response.content if isinstance(response.content, list) else []
refusal = _additional_kwargs(response).get("refusal") or any(
isinstance(block, Mapping) and (block.get("type") == "refusal" or (block.get("type") == "non_standard" and isinstance(block.get("value"), Mapping) and block["value"].get("refusal"))) for block in blocks
)
# Explicit refusal wins even when an adapter also reports normal stop.
# Length/safety stops and unknown outcomes retain their failure behavior.
if not refusal and reasons and all(isinstance(reason, str) and reason.casefold() in {"stop", "end_turn"} for reason in reasons):
raise GoalEvaluationFormatError("The completed goal evaluator response was malformed.") from exc
raise
def _goal_evaluator_usage_record(response: Any, *, source_id: str, model_name: str | None, model: Any) -> dict[str, int | str | None]:

View File

@ -71,6 +71,7 @@ from deerflow.runtime.events.message_identity import attach_message_seq, message
from deerflow.runtime.goal import (
DEFAULT_MAX_GOAL_CONTINUATIONS,
DEFAULT_MAX_NO_PROGRESS_CONTINUATIONS,
GoalEvaluationFormatError,
GoalWriteConflict,
_call_checkpointer_method,
_is_visible_message,
@ -2660,20 +2661,36 @@ async def _prepare_goal_continuation_input(
try:
evaluator_model = evaluator_model_factory() if evaluator_model_factory is not None else None
evaluation = await evaluate_goal_completion(
goal,
messages,
model=evaluator_model,
model_name=model_name,
app_config=app_config,
thread_id=thread_id,
user_id=user_id,
deerflow_trace_id=deerflow_trace_id,
task_store=task_store,
extensions=extensions,
interaction_policy=interaction_policy,
usage_callback=usage_callback,
)
for attempt in range(2):
try:
evaluation = await evaluate_goal_completion(
goal,
messages,
model=evaluator_model,
model_name=model_name,
app_config=app_config,
thread_id=thread_id,
user_id=user_id,
deerflow_trace_id=deerflow_trace_id,
task_store=task_store,
extensions=extensions,
interaction_policy=interaction_policy,
usage_callback=usage_callback,
)
break
except GoalEvaluationFormatError:
if attempt:
raise
if abort_event is not None and abort_event.is_set():
return None
current_goal, current_checkpoint = await _reread_goal_and_checkpoint(checkpointer, thread_id)
if not _goal_instance_matches(goal, current_goal) or current_checkpoint is None:
return None
if _checkpoint_id(current_checkpoint) != checkpoint_id_before or visible_conversation_signature(await _materialized_checkpoint_messages(accessor, thread_id)) != conversation_signature_before:
return None
if abort_event is not None and abort_event.is_set():
return None
logger.info("Retrying one malformed goal evaluation for thread %s after run %s", thread_id, run_id)
except Exception as exc:
logger.warning("Goal evaluator failed for thread %s after run %s", thread_id, run_id, exc_info=True)
if abort_event is not None and abort_event.is_set():

View File

@ -30,13 +30,19 @@ class ScriptedModel(BaseChatModel):
answer: str
identifier: str
reported_usage: dict | None = None
answers: list[str] | None = None
normal_completion: bool = False
@property
def _llm_type(self):
return "scripted-accounting"
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
response = AIMessage(content=self.answer, usage_metadata=self.reported_usage, response_metadata={"model_name": self.identifier})
answer = self.answers.pop(0) if self.answers else self.answer
metadata = {"model_name": self.identifier}
if self.normal_completion:
metadata["finish_reason"] = "stop"
response = AIMessage(content=answer, usage_metadata=self.reported_usage, response_metadata=metadata)
return ChatResult(generations=[ChatGeneration(message=response)])
@ -55,7 +61,7 @@ async def run_store(request):
@pytest.mark.asyncio
@pytest.mark.parametrize("response", ["valid", "invalid_json", "missing_usage"])
@pytest.mark.parametrize("response", ["valid", "invalid_json", "missing_usage", "recovered_json", "repeated_invalid_json", "unsatisfied_json"])
async def test_worker_terminal_ledger_includes_evaluator_usage_without_visible_critic(run_store, monkeypatch, response):
events = MemoryRunEventStore()
checkpointer = InMemorySaver()
@ -63,8 +69,12 @@ async def test_worker_terminal_ledger_includes_evaluator_usage_without_visible_c
metadata = {"scheduled_task_id": "task", "scheduled_task_run_id": "occurrence", "scheduled_goal_objective": "Report"}
record = await manager.create_or_reject("result", user_id="alice", metadata=metadata)
lead = ScriptedModel(answer="The report is verified.", identifier="lead-model", reported_usage={"input_tokens": 30, "output_tokens": 10, "total_tokens": 40})
verdict = json.dumps({"satisfied": response != "unsatisfied_json", "blocker": "missing_evidence", "reason": "PRIVATE_CRITIC_VERDICT", "relied_on_assumption": False})
critic_calls = 2 if response in {"recovered_json", "repeated_invalid_json"} else 1
critic = ScriptedModel(
answer="PRIVATE_CRITIC_INVALID" if response == "invalid_json" else json.dumps({"satisfied": True, "reason": "PRIVATE_CRITIC_VERDICT", "relied_on_assumption": False}),
answer="PRIVATE_CRITIC_INVALID" if response in {"invalid_json", "repeated_invalid_json"} else verdict,
answers=["PRIVATE_CRITIC_INVALID", verdict] if response == "recovered_json" else None,
normal_completion=response in {"recovered_json", "repeated_invalid_json", "unsatisfied_json"},
identifier="critic-model",
reported_usage=None if response == "missing_usage" else {"input_tokens": 80, "output_tokens": 20, "total_tokens": 100, "input_token_details": {"cache_read": 32}},
)
@ -90,7 +100,7 @@ async def test_worker_terminal_ledger_includes_evaluator_usage_without_visible_c
config={"configurable": {"thread_id": "result"}, "context": {"non_interactive": True}},
)
row = await run_store.get(record.run_id, user_id="alice")
assert row["llm_call_count"] == 2
assert row["llm_call_count"] == 1 + critic_calls
assert row["lead_agent_tokens"] == 40
pricing = {"lead-model": _ModelPricing(2, 4, "USD"), "critic-model": _ModelPricing(2, 4, "USD", 0.5)}
cost = _run_cost(pricing, model_name="lead-model", total_input_tokens=row["total_input_tokens"], total_output_tokens=row["total_output_tokens"], token_usage_by_model=row["token_usage_by_model"])
@ -98,12 +108,15 @@ async def test_worker_terminal_ledger_includes_evaluator_usage_without_visible_c
assert row["token_usage_by_model"]["critic-model"]["missing_usage_calls"] == 1
assert cost is None
else:
assert row["middleware_tokens"] == 100
assert row["total_input_tokens"] == 110
assert row["total_output_tokens"] == 30
assert row["total_tokens"] == 140
assert row["token_usage_by_model"]["critic-model"]["cache_read_tokens"] == 32
assert cost == pytest.approx(0.000292)
assert row["middleware_tokens"] == 100 * critic_calls
assert row["total_input_tokens"] == 30 + 80 * critic_calls
assert row["total_output_tokens"] == 10 + 20 * critic_calls
assert row["total_tokens"] == 40 + 100 * critic_calls
assert row["token_usage_by_model"]["critic-model"]["cache_read_tokens"] == 32 * critic_calls
assert cost == pytest.approx(0.0001 + 0.000192 * critic_calls)
if response in {"recovered_json", "repeated_invalid_json", "unsatisfied_json"}:
assert row["goal_verdict"]["satisfied"] is (response == "recovered_json")
assert row["goal_verdict"]["continuations"] == 0
history = await events.list_messages("result", user_id="alice")
assert "PRIVATE_CRITIC" not in json.dumps(history)

View File

@ -366,12 +366,29 @@ def test_format_visible_conversation_keeps_a_long_request_whole_when_it_fits():
assert lines[1] == f"User: {request}"
def test_format_visible_conversation_uses_spare_budget_for_long_user_input_at_the_cap():
request = "Review the supplied data. " + "x" * 5000 + " IMPORTANT SOURCE ROW: 314159 "
request += "y" * (10000 - len(request))
answer = "The result is " + "a" * (goal.MAX_GOAL_CONVERSATION_CHARS - len(f"User: {request}\n\nAssistant: The result is done.")) + " done."
assert len(f"User: {request}\n\nAssistant: {answer}") == goal.MAX_GOAL_CONVERSATION_CHARS
for text in (answer, answer.replace("The result", "The result", 1)):
formatted = goal.format_visible_conversation([HumanMessage(content=request), AIMessage(content=text)])
lines = _evidence_lines(formatted)
# Crossing the cap by one character must not discard most of the input
# while leaving room unused. Preserve the selected recent answer too.
assert "IMPORTANT SOURCE ROW: 314159" in lines[0]
assert len(lines[0]) >= 9900
assert lines[-1] == f"Assistant: {text}"
assert len(formatted) >= goal.MAX_GOAL_CONVERSATION_CHARS - 64
def test_format_visible_conversation_shortens_a_request_that_does_not_fit():
request = "Build the table. " + "r" * 5000
lines = _evidence_lines(goal.format_visible_conversation(_report_run(request)))
assert lines[0].startswith("User: Build the table. ") and lines[0].endswith(" more chars]")
assert len(lines[0]) < goal.MAX_GOAL_REQUEST_CHARS + 40
assert len(lines[0]) < len(f"User: {request}")
def test_format_visible_conversation_marks_the_cut_when_no_request_is_in_the_window():
@ -464,6 +481,28 @@ def test_make_goal_continuation_message_is_hidden_from_ui():
assert "Tests have not run" in message.content
@pytest.mark.asyncio
@pytest.mark.parametrize(
("metadata", "answer", "extra", "retryable"),
[
({"finish_reason": "stop"}, '{"satisfied":', {}, True),
({"stop_reason": "end_turn"}, '{"satisfied": "yes"}', {}, True),
({"status": "completed", "finish_reason": "length"}, '{"satisfied":', {}, False),
({"finish_reason": "content_filter"}, "refused", {}, False),
({"stop_reason": "refusal"}, "refused", {}, False),
({}, '{"satisfied":', {}, False),
({"finish_reason": "stop"}, "", {"refusal": "Not allowed"}, False),
({"finish_reason": "stop"}, [{"type": "refusal", "refusal": "Not allowed"}], {}, False),
],
)
async def test_evaluator_format_retry_requires_a_normal_completion(metadata, answer, extra, retryable):
model = SimpleNamespace(ainvoke=AsyncMock(return_value=AIMessage(content=answer, response_metadata=metadata, additional_kwargs=extra)))
with pytest.raises(ValueError) as error:
await goal.evaluate_goal_completion(goal.build_goal_state("Report"), [HumanMessage(content="Report"), AIMessage(content="Done")], model=model)
assert isinstance(error.value, goal.GoalEvaluationFormatError) is retryable
model.ainvoke.assert_awaited_once()
def test_evaluate_goal_completion_uses_non_thinking_model(monkeypatch):
fake_model = MagicMock()
fake_model.ainvoke = AsyncMock(return_value=SimpleNamespace(content='{"satisfied": true, "reason": "Done", "evidence_summary": "Done"}'))

View File

@ -802,7 +802,8 @@ async def test_goal_worker_stands_down_without_durable_assistant_receipt():
class _FailingEvaluatorModel:
"""Evaluator chat model whose provider call raises, or whose answer is not valid JSON."""
def __init__(self, outcome: Exception | str, before=None) -> None:
def __init__(self, outcome: Exception | str, before=None, *, normal_completion=False) -> None:
self._normal_completion = normal_completion
self._outcome = outcome
self._before = before
self.calls = 0
@ -813,7 +814,7 @@ class _FailingEvaluatorModel:
await self._before()
if isinstance(self._outcome, Exception):
raise self._outcome
return AIMessage(content=self._outcome)
return AIMessage(content=self._outcome, response_metadata={"finish_reason": "stop"} if self._normal_completion else {})
@pytest.mark.asyncio
@ -864,6 +865,46 @@ async def test_goal_worker_records_a_failed_evaluator_call(outcome, error_type,
assert "request-detail-7f3a" not in repr(bridge.events)
@pytest.mark.asyncio
@pytest.mark.parametrize("user_change", ["abort", "clear", "replace", "message"])
async def test_goal_format_retry_does_not_outlive_a_user_change(user_change):
saver = InMemorySaver()
thread_id = "format-retry-user-change"
await _seed_goal_thread(saver, thread_id=thread_id, goal_text="Finish the report")
abort_event = asyncio.Event()
async def change():
if user_change == "abort":
abort_event.set()
elif user_change == "clear":
await write_thread_goal(saver, thread_id, None)
elif user_change == "replace":
await write_thread_goal(saver, thread_id, build_goal_state("A different request"))
else:
await _write_messages(saver, thread_id=thread_id, messages=[HumanMessage(content="Actually, wait for me.")])
critic = _FailingEvaluatorModel("{PRIVATE_CRITIC", before=change, normal_completion=True)
result = await worker._prepare_goal_continuation_input(
accessor=_full_accessor(saver),
bridge=_CollectingBridge(),
checkpointer=saver,
thread_id=thread_id,
run_id="format-retry",
model_name=None,
app_config=None,
evaluator_model_factory=lambda: critic,
abort_event=abort_event,
)
assert result is None
assert critic.calls == 1
current_goal = await read_thread_goal(saver, thread_id)
if user_change == "clear":
assert current_goal is None
else:
assert current_goal["continuation_count"] == current_goal["no_progress_count"] == 0
assert "last_evaluation" not in current_goal
@pytest.mark.asyncio
async def test_goal_worker_does_not_record_a_failed_evaluator_call_after_abort():
checkpointer = InMemorySaver()