mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 02:46:02 +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>
236 lines
8.1 KiB
Python
236 lines
8.1 KiB
Python
from collections.abc import Mapping
|
|
from typing import Annotated, NotRequired, TypedDict
|
|
|
|
from langchain.agents import AgentState
|
|
|
|
from deerflow.agents.goal_state import GoalState
|
|
from deerflow.subagents.status_contract import SUBAGENT_STATUS_VALUES
|
|
|
|
|
|
class SandboxState(TypedDict):
|
|
sandbox_id: NotRequired[str | None]
|
|
|
|
|
|
class ThreadDataState(TypedDict):
|
|
workspace_path: NotRequired[str | None]
|
|
uploads_path: NotRequired[str | None]
|
|
outputs_path: NotRequired[str | None]
|
|
|
|
|
|
class ViewedImageData(TypedDict):
|
|
base64: str
|
|
mime_type: str
|
|
|
|
|
|
def merge_sandbox(existing: SandboxState | None, new: SandboxState | None) -> SandboxState | None:
|
|
"""Reducer for sandbox state - accepts idempotent writes only.
|
|
|
|
Multiple sandbox tools can initialize lazily in the same graph step and
|
|
emit the same sandbox_id via Command(update=...). LangGraph needs an
|
|
explicit reducer for that shared state key. Different sandbox ids in the
|
|
same thread indicate a lifecycle/isolation bug, so fail closed instead of
|
|
choosing one silently.
|
|
"""
|
|
if new is None:
|
|
return existing
|
|
if existing is None:
|
|
return new
|
|
|
|
existing_id = existing.get("sandbox_id")
|
|
new_id = new.get("sandbox_id")
|
|
if existing_id == new_id:
|
|
return existing
|
|
raise ValueError(f"Conflicting sandbox state updates: {existing_id!r} != {new_id!r}")
|
|
|
|
|
|
SandboxStateField = Annotated[NotRequired[SandboxState | None], merge_sandbox]
|
|
|
|
|
|
def merge_artifacts(existing: list[str] | None, new: list[str] | None) -> list[str]:
|
|
"""Reducer for artifacts list - merges and deduplicates artifacts."""
|
|
if existing is None:
|
|
return new or []
|
|
if new is None:
|
|
return existing
|
|
# Use dict.fromkeys to deduplicate while preserving order
|
|
return list(dict.fromkeys(existing + new))
|
|
|
|
|
|
def merge_viewed_images(existing: dict[str, ViewedImageData] | None, new: dict[str, ViewedImageData] | None) -> dict[str, ViewedImageData]:
|
|
"""Reducer for viewed_images dict - merges image dictionaries.
|
|
|
|
Special case: If new is an empty dict {}, it clears the existing images.
|
|
This allows middlewares to clear the viewed_images state after processing.
|
|
"""
|
|
if existing is None:
|
|
return new or {}
|
|
if new is None:
|
|
return existing
|
|
# Special case: empty dict means clear all viewed images
|
|
if len(new) == 0:
|
|
return {}
|
|
# Merge dictionaries, new values override existing ones for same keys
|
|
return {**existing, **new}
|
|
|
|
|
|
def merge_todos(existing: list | None, new: list | None) -> list | None:
|
|
"""Reducer for todos list - keeps the last non-None value.
|
|
|
|
Semantics:
|
|
- If `new` is None (node didn't touch todos), preserve `existing`.
|
|
- If `new` is provided (even empty list), it represents an explicit
|
|
update and wins over `existing`.
|
|
"""
|
|
if new is None:
|
|
return existing
|
|
return new
|
|
|
|
|
|
def merge_goal(existing: GoalState | None, new: GoalState | None) -> GoalState | None:
|
|
"""Reducer for goal state - preserves existing when a node does not touch it."""
|
|
if new is None:
|
|
return existing
|
|
return new
|
|
|
|
|
|
class PromotedTools(TypedDict):
|
|
catalog_hash: str
|
|
names: list[str]
|
|
|
|
|
|
def merge_promoted(existing: PromotedTools | None, new: PromotedTools | None) -> PromotedTools | None:
|
|
"""Reducer for deferred-tool promotions, scoped by catalog hash.
|
|
|
|
- new None/empty -> preserve existing (node didn't touch promotions).
|
|
- catalog_hash changed -> replace wholesale, dropping stale names (prevents a
|
|
persisted bare name from exposing a different tool after catalog drift).
|
|
- same catalog_hash -> union names, dedupe, preserve order.
|
|
"""
|
|
if not new:
|
|
return existing
|
|
if existing is None or existing.get("catalog_hash") != new["catalog_hash"]:
|
|
return {
|
|
"catalog_hash": new["catalog_hash"],
|
|
"names": list(dict.fromkeys(new["names"])),
|
|
}
|
|
return {
|
|
"catalog_hash": existing["catalog_hash"],
|
|
"names": list(dict.fromkeys(existing["names"] + new["names"])),
|
|
}
|
|
|
|
|
|
TERMINAL_STATUSES: frozenset[str] = frozenset(SUBAGENT_STATUS_VALUES)
|
|
_DELEGATION_LEDGER_MAX_ENTRIES = 50
|
|
|
|
|
|
class DelegationEntry(TypedDict):
|
|
id: str
|
|
description: str
|
|
subagent_type: str
|
|
status: str
|
|
result_brief: NotRequired[str]
|
|
result_sha256: NotRequired[str]
|
|
result_ref: NotRequired[str]
|
|
created_at: str
|
|
|
|
|
|
def merge_delegations(existing: list[DelegationEntry] | None, new: list[DelegationEntry] | None) -> list[DelegationEntry]:
|
|
"""Reducer for the delegation ledger.
|
|
|
|
- new None/empty -> preserve existing.
|
|
- append entries, replacing same id with the latest version while preserving
|
|
first-seen order.
|
|
- terminal status is never overwritten by a non-terminal status.
|
|
"""
|
|
if not new:
|
|
return existing or []
|
|
|
|
by_id: dict[str, DelegationEntry] = {}
|
|
order: list[str] = []
|
|
for entry in [*(existing or []), *new]:
|
|
entry_id = entry["id"]
|
|
previous = by_id.get(entry_id)
|
|
if previous is not None and previous["status"] in TERMINAL_STATUSES and entry["status"] not in TERMINAL_STATUSES:
|
|
continue
|
|
if entry_id not in by_id:
|
|
order.append(entry_id)
|
|
elif previous.get("created_at"):
|
|
entry = {**entry, "created_at": previous["created_at"]}
|
|
by_id[entry_id] = entry
|
|
merged = [by_id[entry_id] for entry_id in order]
|
|
if len(merged) > _DELEGATION_LEDGER_MAX_ENTRIES:
|
|
merged = merged[-_DELEGATION_LEDGER_MAX_ENTRIES:]
|
|
return merged
|
|
|
|
|
|
_SKILL_CONTEXT_MAX_ENTRIES = 8
|
|
_SKILL_DESCRIPTION_MAX_CHARS = 500
|
|
|
|
|
|
class SkillEntry(TypedDict):
|
|
name: str
|
|
path: str
|
|
description: str
|
|
loaded_at: int
|
|
|
|
|
|
def _normalize_skill_entry(entry: Mapping[str, object]) -> SkillEntry:
|
|
"""Drop legacy payload keys before storing skill_context back to state."""
|
|
description = entry.get("description")
|
|
loaded_at = entry.get("loaded_at")
|
|
return {
|
|
"name": str(entry.get("name") or ""),
|
|
"path": str(entry["path"]),
|
|
"description": " ".join(description.split())[:_SKILL_DESCRIPTION_MAX_CHARS] if isinstance(description, str) else "",
|
|
"loaded_at": loaded_at if isinstance(loaded_at, int) else 0,
|
|
}
|
|
|
|
|
|
def merge_skill_context(existing: list[SkillEntry] | None, new: list[SkillEntry] | None) -> list[SkillEntry]:
|
|
"""Reducer for the skill-context channel.
|
|
|
|
- new None/empty -> preserve existing.
|
|
- legacy entries are normalized to references; verbatim body keys are dropped.
|
|
- dedup by ``path``; later reads refresh recency and replace the reference.
|
|
- cap by keeping the most recently read entries. ``loaded_at`` is
|
|
observational only because message indices reset after compaction.
|
|
"""
|
|
normalized_existing = [_normalize_skill_entry(entry) for entry in existing or []]
|
|
if not new:
|
|
return normalized_existing
|
|
|
|
by_path: dict[str, SkillEntry] = {}
|
|
order: list[str] = []
|
|
for entry in normalized_existing:
|
|
path = entry["path"]
|
|
if path not in by_path:
|
|
order.append(path)
|
|
by_path[path] = entry
|
|
|
|
for entry in (_normalize_skill_entry(entry) for entry in new):
|
|
path = entry["path"]
|
|
if path in by_path:
|
|
order.remove(path)
|
|
order.append(path)
|
|
by_path[path] = entry
|
|
|
|
merged = [by_path[path] for path in order]
|
|
if len(merged) > _SKILL_CONTEXT_MAX_ENTRIES:
|
|
merged = merged[-_SKILL_CONTEXT_MAX_ENTRIES:]
|
|
return merged
|
|
|
|
|
|
class ThreadState(AgentState):
|
|
sandbox: SandboxStateField
|
|
thread_data: NotRequired[ThreadDataState | None]
|
|
title: NotRequired[str | None]
|
|
artifacts: Annotated[list[str], merge_artifacts]
|
|
todos: Annotated[list | None, merge_todos]
|
|
goal: Annotated[GoalState | None, merge_goal]
|
|
uploaded_files: NotRequired[list[dict] | None]
|
|
viewed_images: Annotated[dict[str, ViewedImageData], merge_viewed_images] # image_path -> {base64, mime_type}
|
|
promoted: Annotated[PromotedTools | None, merge_promoted]
|
|
delegations: Annotated[list[DelegationEntry], merge_delegations]
|
|
skill_context: Annotated[list[SkillEntry], merge_skill_context]
|
|
summary_text: NotRequired[str | None]
|