mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-27 00:17:53 +00:00
* implement goal continuations * fix(goal): address review findings for goal continuations - goal: key the no-progress breaker on a signature of the latest visible assistant evidence instead of the evaluator's volatile free-text, so it actually fires on stalled turns; thread the signature through every worker persist / no-progress call site - goal: align _stand_down_reason default caps with should_continue_goal (8 / 2) so the two gate functions agree on goals missing the fields - runtime: offload the synchronous checkpointer fallback via asyncio.to_thread (goal.py + worker.py) to keep blocking IO off the loop - frontend: i18n the GoalStatus "Goal" label (goalLabel in en/zh/types) - frontend: extract pure composer helpers into input-box-helpers.ts with unit tests (parseGoalCommand, readGoalResponseError, skill suggestions) - tests: cover the evidence-based no-progress and default-cap behavior - docs: align backend/AGENTS.md goal paragraph with actual behavior - e2e: prettier-format chat.spec.ts (fixes the lint-frontend CI failure) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(frontend): hide goal continuation counter until the agent continues The goal status bar rendered a raw "0/8" before any auto-continuation, which read as a mysterious score. Now the counter is hidden until continuation_count > 0, then shows "Continuing N/M" with a tooltip explaining the auto-continuation cap. - Extract getGoalContinuationDisplay into a pure helper (hides at 0) + unit tests - Add goalContinuing / goalContinuationTooltip i18n keys (en/zh/types) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(goal): address review findings for goal continuations Frontend correctness - Fix the optimistic /goal result permanently shadowing server goal state: the streamed continuation counter never surfaced for a goal set in-session. Extract a shared useActiveGoal hook (used by both chat pages) that reconciles the optimistic copy with server state via a goalReconciliationKey, de-duping the copy-pasted goal block across the two pages. - Stop /goal status|clear failures from escaping handleSubmit as unhandled rejections (handleGoalCommand now returns success; the run only starts when a goal was actually saved). - Use a function replacer for the goal-status toast so an objective containing $&/$1 isn't treated as a replacement pattern. Backend cleanliness / correctness - De-duplicate four byte-identical helpers (_call_checkpointer_method, _message_type, _additional_kwargs, _is_visible_message) by importing them from runtime.goal instead of re-defining them in the run worker. - Remove the dead `checkpoint_tuple.tasks` durability guard (CheckpointTuple has no tasks field) and document that pending_writes is the durability signal. - Decompose the 176-line _prepare_goal_continuation_input: extract _reread_goal_and_checkpoint and a _persist closure so the thread-unchanged guard and stand-down persistence aren't open-coded three times. Document the last-writer-wins write-window limitation as a follow-up. - Add a shared parse_goal_command helper and use it from the TUI and IM-channel /goal handlers (one place for the status/clear/set semantics). Tests - Restore the 11 command-registry tests dropped by the previous goal change (filter_commands ranking/description, build_registry builtins/skills, resolve cases) alongside the new goal tests. - Add coverage for the IM-channel _handle_goal_command, the TUI _handle_goal handler, parse_goal_command, and goalReconciliationKey. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix goal review feedback * fix goal continuation checkpoint races * prioritize goal commands while streaming Route composer submits through a shared helper so /goal commands can be handled before the streaming stop shortcut, while ordinary streaming submits still stop the active run. Testing: cd frontend && pnpm exec rstest run tests/unit/components/workspace/input-box-helpers.test.ts tests/unit/components/workspace/goal-status-helpers.test.ts; cd frontend && pnpm check * preserve goal status during clarification Keep omitted stream goal fields distinct from explicit null clears so clarification interrupts do not hide an active thread goal that is still present in the checkpoint. Testing: pnpm exec rstest run tests/unit/components/workspace/use-active-goal.test.ts tests/unit/components/workspace/input-box-helpers.test.ts tests/unit/components/workspace/goal-status-helpers.test.ts; pnpm check; git diff --check * style: format active goal hook Run Prettier on use-active-goal.ts to satisfy the frontend lint workflow formatting gate. Testing: pnpm format; pnpm exec rstest run tests/unit/components/workspace/use-active-goal.test.ts tests/unit/components/workspace/input-box-helpers.test.ts tests/unit/components/workspace/goal-status-helpers.test.ts; pnpm check; git diff --check * fix goal review followups --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
237 lines
10 KiB
Python
237 lines
10 KiB
Python
import asyncio
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
|
|
|
from deerflow.runtime import goal
|
|
|
|
|
|
def test_build_goal_state_defaults_to_claude_stop_hook_cap():
|
|
state = goal.build_goal_state("Finish the tests")
|
|
|
|
assert state["objective"] == "Finish the tests"
|
|
assert state["status"] == "active"
|
|
assert state["continuation_count"] == 0
|
|
assert state["max_continuations"] == 8
|
|
assert state["no_progress_count"] == 0
|
|
assert state["max_no_progress_continuations"] == 2
|
|
assert state["created_at"]
|
|
|
|
|
|
def test_parse_goal_evaluation_extracts_json_object_from_fenced_response():
|
|
parsed = goal.parse_goal_evaluation_response('```json\n{"satisfied": true, "reason": "All requested tests pass.", "evidence_summary": "pytest passed"}\n```')
|
|
|
|
assert parsed["satisfied"] is True
|
|
assert parsed["blocker"] == "none"
|
|
assert parsed["reason"] == "All requested tests pass."
|
|
assert parsed["evidence_summary"] == "pytest passed"
|
|
|
|
|
|
def test_parse_goal_evaluation_strips_think_blocks():
|
|
parsed = goal.parse_goal_evaluation_response('<think>maybe {"satisfied": false}</think>\n{"satisfied": false, "reason": "Missing verification."}')
|
|
|
|
assert parsed["satisfied"] is False
|
|
assert parsed["blocker"] == "missing_evidence"
|
|
assert parsed["reason"] == "Missing verification."
|
|
|
|
|
|
def test_parse_goal_evaluation_preserves_typed_blocker():
|
|
parsed = goal.parse_goal_evaluation_response('{"satisfied": false, "blocker": "needs_user_input", "reason": "The user must choose a deployment target."}')
|
|
|
|
assert parsed["satisfied"] is False
|
|
assert parsed["blocker"] == "needs_user_input"
|
|
|
|
|
|
def test_format_visible_conversation_excludes_hidden_and_system_messages():
|
|
messages = [
|
|
SystemMessage(content="internal"),
|
|
HumanMessage(content="visible user"),
|
|
HumanMessage(content="hidden control", additional_kwargs={"hide_from_ui": True}),
|
|
AIMessage(content="visible assistant"),
|
|
]
|
|
|
|
formatted = goal.format_visible_conversation(messages)
|
|
|
|
assert "visible user" in formatted
|
|
assert "visible assistant" in formatted
|
|
assert "hidden control" not in formatted
|
|
assert "internal" not in formatted
|
|
|
|
|
|
def test_should_continue_goal_respects_completion_and_cap():
|
|
active = goal.build_goal_state("Finish", max_continuations=2)
|
|
unmet = goal.GoalEvaluation(satisfied=False, blocker="goal_not_met_yet", reason="not yet")
|
|
met = goal.GoalEvaluation(satisfied=True, blocker="none", reason="done")
|
|
missing_evidence = goal.GoalEvaluation(satisfied=False, blocker="missing_evidence", reason="weak transcript")
|
|
|
|
assert goal.should_continue_goal(active, unmet) is True
|
|
assert goal.should_continue_goal({**active, "continuation_count": 2}, unmet) is False
|
|
assert goal.should_continue_goal(active, met) is False
|
|
assert goal.should_continue_goal(active, missing_evidence) is False
|
|
|
|
|
|
def test_should_continue_goal_respects_no_progress_cap():
|
|
active = goal.build_goal_state("Finish")
|
|
unmet = goal.GoalEvaluation(satisfied=False, blocker="goal_not_met_yet", reason="same evidence")
|
|
|
|
assert goal.should_continue_goal(active, unmet, no_progress_count=0) is True
|
|
assert goal.should_continue_goal(active, unmet, no_progress_count=active["max_no_progress_continuations"]) is False
|
|
|
|
|
|
def test_make_goal_continuation_message_is_hidden_from_ui():
|
|
state = goal.build_goal_state("Finish the implementation")
|
|
evaluation = goal.GoalEvaluation(satisfied=False, blocker="goal_not_met_yet", reason="Tests have not run")
|
|
|
|
message = goal.make_goal_continuation_message(state, evaluation)
|
|
|
|
assert message.additional_kwargs["hide_from_ui"] is True
|
|
assert "Finish the implementation" in message.content
|
|
assert "Tests have not run" in message.content
|
|
|
|
|
|
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"}'))
|
|
captured = {}
|
|
|
|
def fake_create_chat_model(**kwargs):
|
|
captured.update(kwargs)
|
|
return fake_model
|
|
|
|
monkeypatch.setattr(goal, "create_chat_model", fake_create_chat_model)
|
|
state = goal.build_goal_state("Finish")
|
|
|
|
result = asyncio.run(
|
|
goal.evaluate_goal_completion(
|
|
state,
|
|
[
|
|
HumanMessage(content="Please finish this."),
|
|
AIMessage(content="Done."),
|
|
],
|
|
app_config=object(),
|
|
)
|
|
)
|
|
|
|
assert result["satisfied"] is True
|
|
assert result["blocker"] == "none"
|
|
assert captured["thinking_enabled"] is False
|
|
assert captured["attach_tracing"] is False
|
|
fake_model.ainvoke.assert_awaited_once()
|
|
assert fake_model.ainvoke.await_args.kwargs["config"] == {"run_name": "goal_evaluator"}
|
|
|
|
|
|
def test_evaluate_goal_completion_uses_injected_model(monkeypatch):
|
|
fake_model = MagicMock()
|
|
fake_model.ainvoke = AsyncMock(return_value=SimpleNamespace(content='{"satisfied": true, "reason": "Done", "evidence_summary": "Done"}'))
|
|
create_chat_model = MagicMock()
|
|
monkeypatch.setattr(goal, "create_chat_model", create_chat_model)
|
|
state = goal.build_goal_state("Finish")
|
|
|
|
result = asyncio.run(
|
|
goal.evaluate_goal_completion(
|
|
state,
|
|
[
|
|
HumanMessage(content="Please finish this."),
|
|
AIMessage(content="Done."),
|
|
],
|
|
model=fake_model,
|
|
app_config=object(),
|
|
)
|
|
)
|
|
|
|
assert result["satisfied"] is True
|
|
create_chat_model.assert_not_called()
|
|
fake_model.ainvoke.assert_awaited_once()
|
|
|
|
|
|
def test_evaluate_goal_completion_fails_closed_without_assistant_evidence(monkeypatch):
|
|
fake_model = MagicMock()
|
|
fake_model.ainvoke = AsyncMock()
|
|
monkeypatch.setattr(goal, "create_chat_model", lambda **_kwargs: fake_model)
|
|
state = goal.build_goal_state("Finish")
|
|
|
|
result = asyncio.run(goal.evaluate_goal_completion(state, [HumanMessage(content="please do it")], app_config=object()))
|
|
|
|
assert result["satisfied"] is False
|
|
assert result["blocker"] == "missing_evidence"
|
|
fake_model.ainvoke.assert_not_called()
|
|
|
|
|
|
def test_attach_goal_evaluation_records_blocker_progress_and_stand_down_reason():
|
|
state = goal.build_goal_state("Finish")
|
|
evaluation = goal.GoalEvaluation(
|
|
satisfied=False,
|
|
blocker="external_wait",
|
|
reason="Waiting for deployment",
|
|
evidence_summary="Deploy is pending",
|
|
)
|
|
|
|
updated = goal.attach_goal_evaluation(
|
|
state,
|
|
evaluation,
|
|
run_id="run-1",
|
|
no_progress_count=1,
|
|
stand_down_reason="blocked:external_wait",
|
|
)
|
|
|
|
assert updated["no_progress_count"] == 1
|
|
assert updated["last_evaluation"]["blocker"] == "external_wait"
|
|
assert updated["last_evaluation"]["evidence_summary"] == "Deploy is pending"
|
|
assert updated["last_evaluation"]["stand_down_reason"] == "blocked:external_wait"
|
|
assert updated["last_evaluation"]["progress_key"]
|
|
|
|
|
|
def test_latest_visible_assistant_signature_tracks_last_ai_evidence():
|
|
base = [HumanMessage(content="go"), AIMessage(content="answer one")]
|
|
sig1 = goal.latest_visible_assistant_signature(base)
|
|
# Signature depends only on the latest visible assistant text, not the prompt.
|
|
sig1_again = goal.latest_visible_assistant_signature([HumanMessage(content="different prompt"), AIMessage(content="answer one")])
|
|
# It changes when the assistant produces new output.
|
|
sig2 = goal.latest_visible_assistant_signature([HumanMessage(content="go"), AIMessage(content="answer two")])
|
|
|
|
assert sig1 and sig1 == sig1_again
|
|
assert sig1 != sig2
|
|
# Hidden continuations and human-only transcripts contribute no evidence.
|
|
hidden = AIMessage(content="hidden", additional_kwargs={"hide_from_ui": True})
|
|
assert goal.latest_visible_assistant_signature([HumanMessage(content="only human"), hidden]) == ""
|
|
|
|
|
|
def test_no_progress_count_keys_on_evidence_not_volatile_free_text():
|
|
"""The breaker must survive the evaluator rewording its reason.
|
|
|
|
Same visible assistant evidence + reworded free-text reason/evidence_summary
|
|
must still count as 'no progress'. The previous implementation keyed on the
|
|
volatile free-text, so the breaker effectively never fired.
|
|
"""
|
|
evidence = "I made a start, but I am not done."
|
|
first = goal.GoalEvaluation(satisfied=False, blocker="goal_not_met_yet", reason="The same work remains.", evidence_summary="No new verification evidence.")
|
|
prior = goal.attach_goal_evaluation(goal.build_goal_state("Finish"), first, run_id="r1", no_progress_count=0, evidence_signature=evidence)
|
|
|
|
reworded = goal.GoalEvaluation(satisfied=False, blocker="goal_not_met_yet", reason="Still the same outstanding work, phrased differently.", evidence_summary="Evidence remains thin; nothing new verified.")
|
|
assert goal.compute_no_progress_count(prior, reworded, evidence_signature=evidence) == 1
|
|
|
|
|
|
def test_no_progress_count_resets_when_evidence_advances():
|
|
first = goal.GoalEvaluation(satisfied=False, blocker="goal_not_met_yet", reason="x", evidence_summary="y")
|
|
prior = goal.attach_goal_evaluation(goal.build_goal_state("Finish"), first, run_id="r1", no_progress_count=1, evidence_signature="step 1 done")
|
|
|
|
# Identical evaluator wording, but the agent produced NEW visible evidence -> progress.
|
|
assert goal.compute_no_progress_count(prior, first, evidence_signature="step 2 done") == 0
|
|
|
|
|
|
def test_parse_goal_command_status_for_empty_and_whitespace():
|
|
assert goal.parse_goal_command("") == goal.GoalCommand("status")
|
|
assert goal.parse_goal_command(" ") == goal.GoalCommand("status")
|
|
|
|
|
|
def test_parse_goal_command_clear_aliases_case_insensitive():
|
|
for alias in ("clear", "reset", "off", "CLEAR", " Reset ", "Off"):
|
|
assert goal.parse_goal_command(alias) == goal.GoalCommand("clear")
|
|
|
|
|
|
def test_parse_goal_command_set_trims_and_preserves_objective():
|
|
assert goal.parse_goal_command(" finish the work ") == goal.GoalCommand("set", "finish the work")
|
|
# A multi-word objective that merely starts with an alias is a set, not a clear.
|
|
assert goal.parse_goal_command("clear the build cache") == goal.GoalCommand("set", "clear the build cache")
|