feat(runtime): implement goal continuations (#3858)

* 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>
This commit is contained in:
DanielWalnut 2026-07-03 08:49:33 +08:00 committed by GitHub
parent e3e5c73b03
commit 25ea6970a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 3963 additions and 208 deletions

View File

@ -63,6 +63,7 @@ DeerFlow has newly integrated the intelligent search and crawling toolset indepe
- [Core Features](#core-features)
- [Skills \& Tools](#skills--tools)
- [Claude Code Integration](#claude-code-integration)
- [Session Goals](#session-goals)
- [Sub-Agents](#sub-agents)
- [Sandbox \& File System](#sandbox--file-system)
- [Context Engineering](#context-engineering)
@ -669,6 +670,22 @@ DEERFLOW_LANGGRAPH_URL=http://localhost:2026/api/langgraph # LangGraph API
See [`skills/public/claude-to-deerflow/SKILL.md`](skills/public/claude-to-deerflow/SKILL.md) for the full API reference.
### Session Goals
Use `/goal <completion condition>` to attach one active completion condition to the current thread. The goal is thread-scoped state, not a skill activation, so it stays active across turns until DeerFlow determines it has been satisfied or you clear it.
Supported commands:
```text
/goal finish the implementation and make all tests pass
/goal # show the active goal
/goal clear # clear it
```
After each Gateway-backed run, DeerFlow evaluates the visible conversation against the active goal with a non-thinking evaluator model. 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 goal is satisfied, DeerFlow clears it automatically and publishes the updated thread state.
The Web UI shows the active goal above the composer. The same command is available from the TUI and supported IM channels. In the Web UI and supported IM channels, setting `/goal <completion condition>` also starts a run with the condition as the task; status and clear commands only manage goal state.
### Sub-Agents
Complex tasks rarely fit in a single pass. DeerFlow decomposes them.
@ -742,6 +759,9 @@ models = client.list_models() # {"models": [...]}
skills = client.list_skills() # {"skills": [...]}
client.update_skill("web-search", enabled=True)
client.upload_files("thread-1", ["./report.pdf"]) # {"success": True, "files": [...]}
client.set_goal("thread-1", "finish the implementation and make all tests pass")
client.get_goal("thread-1") # {"goal": {...}} or {"goal": None}
client.clear_goal("thread-1")
```
All dict-returning methods are validated against Gateway Pydantic response models in CI (`TestGatewayConformance`), ensuring the embedded client stays in sync with the HTTP API schemas. See `backend/packages/harness/deerflow/client.py` for full API documentation.
@ -762,7 +782,7 @@ deerflow --print "summarize this repo" # headless one-shot answer to stdo
deerflow --json "hello" # headless newline-delimited StreamEvents
```
A keyboard-driven chat surface with a streaming transcript (Markdown-rendered answers), compact tool-activity cards, a `/` slash-command palette, `/model` and `/threads` pickers, input history, and `Esc` / `Ctrl+C` interrupt. Sessions opened in the TUI also appear in the Web UI sidebar — it writes the shared thread store under the local default user, so terminal and web stay in sync **without running the Gateway**.
A keyboard-driven chat surface with a streaming transcript (Markdown-rendered answers), compact tool-activity cards, a `/` slash-command palette, `/goal` goal management, `/model` and `/threads` pickers, input history, and `Esc` / `Ctrl+C` interrupt. Sessions opened in the TUI also appear in the Web UI sidebar — it writes the shared thread store under the local default user, so terminal and web stay in sync **without running the Gateway**.
See [backend/docs/TUI.md](backend/docs/TUI.md) for the full guide.

View File

@ -190,8 +190,8 @@ from deerflow.config import get_app_config
- System prompt generated by `apply_prompt_template()` with skills, memory, and subagent instructions
**ThreadState** (`packages/harness/deerflow/agents/thread_state.py`):
- Extends `AgentState` with: `sandbox`, `thread_data`, `title`, `artifacts`, `todos`, `uploaded_files`, `viewed_images`, `promoted`, `delegations`, `skill_context`, `summary_text`
- Uses custom reducers: `merge_artifacts` (deduplicate), `merge_viewed_images` (merge/clear), `merge_promoted` (catalog-hash-scoped deferred tool promotions), `merge_delegations` (append task delegation entries, same id latest wins, terminal status never downgraded, capped to the most recent entries), and `merge_skill_context` (dedupe active-skill references by path, keep the most recently read entries; entries store a name/path/description reference, not the SKILL.md body). `summary_text` is a LastValue channel updated by summarization and projected into model requests as durable context data instead of being stored as a `messages` item.
- Extends `AgentState` with: `sandbox`, `thread_data`, `title`, `artifacts`, `todos`, `uploaded_files`, `viewed_images`, `goal`, `promoted`, `delegations`, `skill_context`, `summary_text`
- Uses custom reducers: `merge_artifacts` (deduplicate), `merge_viewed_images` (merge/clear), `merge_goal` (preserve the active goal across ordinary state updates unless the goal writer replaces it), `merge_promoted` (catalog-hash-scoped deferred tool promotions), `merge_delegations` (append task delegation entries, same id latest wins, terminal status never downgraded, capped to the most recent entries), and `merge_skill_context` (dedupe active-skill references by path, keep the most recently read entries; entries store a name/path/description reference, not the SKILL.md body). `summary_text` is a LastValue channel updated by summarization and projected into model requests as durable context data instead of being stored as a `messages` item.
**Runtime Configuration** (via `config.configurable`):
- `thinking_enabled` - Enable model's extended thinking
@ -291,7 +291,7 @@ CORS is same-origin by default when requests enter through nginx on port 2026. S
| **Skills** (`/api/skills`) | `GET /` - list skills; `GET /{name}` - details; `PUT /{name}` - update enabled; `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`) |
| **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data |
| **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete |
| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; `GET /goal`, `PUT /goal`, `DELETE /goal` - read, set, and clear the active thread goal; unexpected failures are logged server-side and return a generic 500 detail |
| **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types |
| **Suggestions** (`/api/suggestions`) | `GET /config` - returns global suggestions config boolean; `POST /threads/{id}/suggestions` - generate follow-up questions; rich list/block model content is normalized and inline reasoning (`<think>...</think>`, including unclosed/truncated blocks from reasoning models like MiniMax-M3) is stripped before JSON parsing |
| **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block; `POST /regenerate/prepare` - prepare clean input + checkpoint metadata for regenerating the latest assistant answer; `GET /` - list runs; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `GET /{rid}/messages` - paginated messages `{data, has_more}`; `GET /{rid}/events` - full event stream; `GET /../messages` - thread messages with feedback; `GET /../token-usage` - aggregate tokens |
@ -305,6 +305,7 @@ CORS is same-origin by default when requests enter through nginx on port 2026. S
- Store-only hydrated runs are readable history. If the current worker has no in-memory task/control state for that run, cancellation APIs can return 409 because this worker cannot stop the task.
- `POST /wait` (both thread-scoped and `/api/runs/wait`) drains the stream bridge via `wait_for_run_completion()` instead of bare `await record.task`, so it honours the run's `on_disconnect` setting and cancels the background run on real client disconnect rather than returning a stale checkpoint (issue #3265).
- Thread-scoped run creation accepts `checkpoint` / `checkpoint_id`; Gateway validates the checkpoint belongs to the request thread before writing `checkpoint_id` / `checkpoint_ns` into `config.configurable` for LangGraph branching.
- Thread-scoped Gateway runs evaluate an active `ThreadState.goal` after the visible turn completes. `runtime/goal.py` asks a non-thinking evaluator model to judge only visible conversation evidence and return a typed blocker; the evaluator model is created once per run and reused across hidden continuation checks. Satisfied goals are cleared; every non-satisfied evaluation — continuable or stand-down — is persisted with `last_evaluation` (the blocker, reason, and evidence summary; outcomes that stop the loop additionally record a `stand_down_reason` for observability), but only `goal_not_met_yet` evaluations are streamed as hidden `HumanMessage` continuations, and only when a durable assistant end-of-turn checkpoint exists, the run has not been aborted, the thread did not change during evaluation, and the no-progress breaker has not fired. The continuation cap is 8 — a hard maximum in the `0``8` range; callers requesting more are clamped (`set_goal`/TUI) or rejected with 422 (`PUT /goal`). The no-progress breaker keys on the latest visible assistant evidence (not the evaluator's free-text reason, which an LLM rewords every turn), so two consecutive continuations that add no new visible assistant output stop the loop after 2 attempts. Model-response cleanup helpers such as think-block stripping and code-fence stripping live in `deerflow.utils.llm_text` so `runtime/goal.py` and Gateway suggestion parsing share the same JSON-prep behavior.
Proxied through nginx: `/api/langgraph/*` → Gateway LangGraph-compatible runtime, all other `/api/*` → Gateway REST APIs.
@ -388,7 +389,7 @@ Additional providers also live here (`brave`, `browserless`, `crawl4ai`, `ddg_se
- **Format**: Directory with `SKILL.md` (YAML frontmatter: name, description, license, allowed-tools, required-secrets)
- **Loading**: `load_skills()` recursively scans `skills/{public,custom}` for `SKILL.md`, parses metadata, and reads enabled state from extensions_config.json
- **Injection**: Enabled skills listed in agent system prompt with container paths
- **Slash activation**: `/skill-name task` loads that enabled skill's `SKILL.md` for the current model call only. The resolver rejects leading whitespace, missing separators, reserved channel commands (`/new`, `/help`, `/bootstrap`, `/status`, `/models`, `/memory`), disabled skills, and skills outside a custom agent's whitelist.
- **Slash activation**: `/skill-name task` loads that enabled skill's `SKILL.md` for the current model call only. The resolver rejects leading whitespace, missing separators, reserved channel commands (`/new`, `/help`, `/bootstrap`, `/status`, `/models`, `/memory`, `/goal`), disabled skills, and skills outside a custom agent's whitelist.
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
#### Request-Scoped Secrets (`required-secrets`)
@ -427,7 +428,7 @@ Bridges external messaging platforms (Feishu, Slack, Telegram, Discord, DingTalk
**Components**:
- `message_bus.py` - Async pub/sub hub (`InboundMessage` → queue → dispatcher; `OutboundMessage` → callbacks → channels)
- `store.py` - JSON-file persistence mapping `channel_name:chat_id[:topic_id]``thread_id` (keys are `channel:chat` for root conversations and `channel:chat:topic` for threaded conversations)
- `manager.py` - Core dispatcher: creates threads via `client.threads.create()`, routes commands, keeps Slack/Discord on `client.runs.wait()`, and uses `client.runs.stream(["messages-tuple", "values"])` for Feishu/Telegram incremental outbound updates
- `manager.py` - Core dispatcher: creates threads via `client.threads.create()`, routes commands including `/goal` (setting a goal persists it through Gateway and then routes the objective as a chat turn), keeps Slack/Discord on `client.runs.wait()`, and uses `client.runs.stream(["messages-tuple", "values"])` for Feishu/Telegram incremental outbound updates
- `base.py` - Abstract `Channel` base class (start/stop/send lifecycle)
- `service.py` - Manages lifecycle of all configured channels from `config.yaml`
- `slack.py` / `feishu.py` / `telegram.py` / `discord.py` / `dingtalk.py` - Platform-specific implementations (`feishu.py` tracks the running card `message_id` in memory and patches the same card in place; `telegram.py` registers the "Working on it..." placeholder as the stream target and edits it in place via `editMessageText`; `dingtalk.py` optionally uses AI Card streaming for in-place updates when `card_template_id` is configured)
@ -444,7 +445,7 @@ Bridges external messaging platforms (Feishu, Slack, Telegram, Discord, DingTalk
7. Feishu channel sends one running reply card up front, then patches the same card for each outbound update (card JSON sets `config.update_multi=true` for Feishu's patch API requirement)
8. Telegram streaming: the "Working on it..." placeholder message is registered as the stream target; non-final updates `editMessageText` it in place (channel-side throttle: 1s in private chats, 3s in groups due to Telegram's 20 msg/min group cap; 4096-char truncation; rate-limited updates dropped); the final update performs the last edit and splits >4096 texts into follow-up messages
9. DingTalk AI Card mode (when `card_template_id` configured): `runs.stream()` → create card with initial text → stream updates via `PUT /v1.0/card/streaming` → finalize on `is_final=True`. Falls back to `sampleMarkdown` if card creation or streaming fails
10. For commands (`/new`, `/status`, `/models`, `/memory`, `/help`): handle locally or query Gateway API
10. For commands (`/new`, `/status`, `/models`, `/memory`, `/goal`, `/help`): handle locally or query Gateway API
11. Outbound → channel callbacks → platform reply
**Owner-scoped file storage**: inbound files, uploads, and output artifacts are staged under the DeerFlow owner's bucket so they land where the agent run reads/writes (`users/{user_id}/threads/{thread_id}/user-data/{uploads,outputs}`). `ChannelManager._handle_chat` resolves the storage owner once via `_channel_storage_user_id(msg)` (sanitized owner id, falling back to `safe(msg.user_id)` for unbound auth-enabled channels — mirroring `_resolve_run_params`'s run identity; `None` only when no identity is available) and threads it as the `user_id=` kwarg through the file pipeline:
@ -569,7 +570,7 @@ A terminal-native UI over the embedded harness, exposed as the `deerflow` consol
- `view_state.py``ViewState` + `reduce(state, action)`, the testable heart. Rows: user / assistant / tool / system. Title captured from `values` events.
- `runtime.py``translate(StreamEvent) -> [Action]` (pure) + `stream_actions()` which brackets a run with `RunStarted`/`RunEnded` and turns model errors into an `AssistantError` row.
- `message_format.py` / `command_registry.py` / `input_history.py` / `render.py` / `theme.py` — pure helpers (tool summaries, slash registry + `resolve()`, ↑/↓ history, Rich renderers).
- `app.py` — Textual `App`. Runs `DeerFlowClient.stream()` (sync) on a worker thread and marshals actions to the UI thread via `call_from_thread`. Slash palette + model/thread modal pickers; priority key bindings gated by `check_action` so they never steal keys from overlays or the composer.
- `app.py` — Textual `App`. Runs `DeerFlowClient.stream()` (sync) on a worker thread and marshals actions to the UI thread via `call_from_thread`. Slash palette with `/goal` management + model/thread modal pickers; priority key bindings gated by `check_action` so they never steal keys from overlays or the composer.
- `session.py` / `persistence.py` — builds the client + checkpointer and the `ThreadMetaWriter`.
**Web UI visibility**: the Web UI lists threads from the `threads_meta` SQL table (user-scoped), not the checkpointer. `persistence.py` writes a `threads_meta` row under the default user (`"default"`) into the same DB the Gateway reads — via the harness-only `deerflow.persistence.engine.init_engine_from_config()` — so TUI sessions appear in the Web UI sidebar **without** running the Gateway. Best-effort: a no-op on the `memory` backend. All DB work runs on one long-lived background event loop (a SQLAlchemy async engine is bound to its creating loop).
@ -668,6 +669,7 @@ Both can be modified at runtime via Gateway API endpoints or `DeerFlowClient` me
| Models | `list_models()`, `get_model(name)` | `{"models": [...]}`, `{name, display_name, ...}` |
| MCP | `get_mcp_config()`, `update_mcp_config(servers)` | `{"mcp_servers": {...}}` |
| Skills | `list_skills()`, `get_skill(name)`, `update_skill(name, enabled)`, `install_skill(path)` | `{"skills": [...]}` |
| Goals | `get_goal(thread_id)`, `set_goal(thread_id, objective, max_continuations=8)`, `clear_goal(thread_id)` | `{"goal": {...}}` or `{"goal": None}` |
| Memory | `get_memory()`, `reload_memory()`, `get_memory_config()`, `get_memory_status()` | dict |
| Uploads | `upload_files(thread_id, files)`, `list_uploads(thread_id)`, `delete_upload(thread_id, filename)` | `{"success": true, "files": [...]}`, `{"files": [...], "count": N}` |
| Artifacts | `get_artifact(thread_id, path)``(bytes, mime_type)` | tuple |

View File

@ -11,6 +11,7 @@ from __future__ import annotations
KNOWN_CHANNEL_COMMANDS: frozenset[str] = frozenset(
{
"/bootstrap",
"/goal",
"/new",
"/status",
"/models",

View File

@ -12,6 +12,7 @@ from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import quote
import httpx
from langgraph_sdk.errors import ConflictError
@ -30,6 +31,7 @@ from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, CSRF_HEADER_NAME, gene
from app.gateway.internal_auth import create_internal_auth_headers
from deerflow.config.agents_config import load_agent_config
from deerflow.config.paths import make_safe_user_id
from deerflow.runtime.goal import parse_goal_command
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.skills.slash import parse_slash_skill_reference
from deerflow.skills.storage import get_or_new_skill_storage
@ -1564,10 +1566,15 @@ class ChannelManager:
reply = await self._fetch_gateway("/api/models", "models", msg=msg)
elif reply is None and command == "memory":
reply = await self._fetch_gateway("/api/memory", "memory", msg=msg)
elif reply is None and command == "goal":
reply = await self._handle_goal_command(msg, parts[1] if len(parts) > 1 else "")
if reply is None:
return
elif reply is None and command == "help":
reply = (
"Available commands:\n"
"/bootstrap — Start a bootstrap session (enables agent setup)\n"
"/goal [condition|clear] — Set, show, or clear an active goal\n"
"/new — Start a new conversation\n"
"/status — Show current thread info\n"
"/models — List available models\n"
@ -1606,6 +1613,63 @@ class ChannelManager:
)
await self.bus.publish_outbound(outbound)
async def _goal_request(
self,
method: str,
thread_id: str,
*,
headers: dict[str, str],
json: dict[str, Any] | None = None,
) -> dict[str, Any]:
async with httpx.AsyncClient() as http:
request = getattr(http, method.lower())
kwargs: dict[str, Any] = {"timeout": 10, "headers": headers}
if json is not None:
kwargs["json"] = json
response = await request(f"{self._gateway_url}/api/threads/{quote(thread_id, safe='')}/goal", **kwargs)
response.raise_for_status()
return response.json() or {}
async def _handle_goal_command(self, msg: InboundMessage, args: str) -> str | None:
command = parse_goal_command(args)
thread_id = await self._lookup_thread_id(msg)
headers = _owner_headers(msg) or create_internal_auth_headers()
if command.kind == "status":
if not thread_id:
return "No active goal."
try:
goal = (await self._goal_request("get", thread_id, headers=headers)).get("goal")
except Exception:
logger.exception("Failed to fetch goal from gateway")
return "Failed to fetch goal information."
return f"Goal: {goal.get('objective')}" if goal else "No active goal."
if command.kind == "clear":
if not thread_id:
return "Goal cleared."
try:
await self._goal_request("delete", thread_id, headers=headers)
except Exception:
logger.exception("Failed to clear goal through gateway")
return "Failed to clear goal."
return "Goal cleared."
if not thread_id:
thread_id = await self._create_thread(self._get_client(), msg)
try:
await self._goal_request("put", thread_id, headers=headers, json={"objective": command.objective})
except Exception:
logger.exception("Failed to set goal through gateway")
return "Failed to set goal."
from dataclasses import replace as _dc_replace
chat_msg = _dc_replace(msg, text=command.objective, msg_type=InboundMessageType.CHAT)
await self._handle_chat(chat_msg, bound_identity_checked=True)
return None
async def _fetch_gateway(self, path: str, kind: str, *, msg: InboundMessage | None = None) -> str:
"""Fetch data from the Gateway API for command responses."""
import httpx

View File

@ -86,6 +86,7 @@ class TelegramChannel(Channel):
app.add_handler(CommandHandler("status", self._cmd_generic))
app.add_handler(CommandHandler("models", self._cmd_generic))
app.add_handler(CommandHandler("memory", self._cmd_generic))
app.add_handler(CommandHandler("goal", self._cmd_generic))
app.add_handler(CommandHandler("help", self._cmd_generic))
# Slash skill commands are dynamic and cannot all be pre-registered

View File

@ -1,12 +1,12 @@
import json
import logging
import os
import re
from fastapi import APIRouter, Depends, Request
from langchain_core.messages import HumanMessage, SystemMessage
from pydantic import BaseModel, Field
import deerflow.utils.llm_text as llm_text
from app.gateway.authz import require_permission
from app.gateway.deps import get_config
from deerflow.config.app_config import AppConfig
@ -38,39 +38,9 @@ class SuggestionsConfigResponse(BaseModel):
enabled: bool = Field(..., description="Whether follow-up suggestions are enabled globally")
# Matches a complete <think>...</think> block (case-insensitive, spans newlines).
_THINK_BLOCK_RE = re.compile(r"<think\b[^>]*>.*?</think\s*>", re.IGNORECASE | re.DOTALL)
# Matches a dangling, unclosed <think> (model truncated at max_tokens mid-thought).
_OPEN_THINK_RE = re.compile(r"<think\b[^>]*>", re.IGNORECASE)
def _strip_think_blocks(text: str) -> str:
"""Remove reasoning-model ``<think>...</think>`` blocks from the response.
Reasoning models such as MiniMax-M3 inline their chain-of-thought into the
message ``content`` wrapped in ``<think>...</think>`` (``reasoning_split``
defaults to false), rather than exposing a separate ``reasoning_content``
field. The thinking text frequently contains ``[`` / ``]`` characters, which
corrupted the downstream ``find('[')`` / ``rfind(']')`` JSON extraction and
produced empty suggestions. We strip the reasoning before parsing so only
the actual answer remains.
"""
text = _THINK_BLOCK_RE.sub("", text)
# Drop any unclosed <think> (and everything after it) left by truncation.
open_match = _OPEN_THINK_RE.search(text)
if open_match:
text = text[: open_match.start()]
return text.strip()
def _strip_markdown_code_fence(text: str) -> str:
stripped = text.strip()
if not stripped.startswith("```"):
return stripped
lines = stripped.splitlines()
if len(lines) >= 3 and lines[0].startswith("```") and lines[-1].startswith("```"):
return "\n".join(lines[1:-1]).strip()
return stripped
_extract_response_text = llm_text.extract_response_text
_strip_markdown_code_fence = llm_text.strip_markdown_code_fence
_strip_think_blocks = llm_text.strip_think_blocks
def _parse_json_string_list(text: str) -> list[str] | None:
@ -98,24 +68,6 @@ def _parse_json_string_list(text: str) -> list[str] | None:
return out
def _extract_response_text(content: object) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict) and block.get("type") in {"text", "output_text"}:
text = block.get("text")
if isinstance(text, str):
parts.append(text)
return "\n".join(parts) if parts else ""
if content is None:
return ""
return str(content)
def _format_conversation(messages: list[SuggestionMessage]) -> str:
parts: list[str] = []
for m in messages:

View File

@ -26,6 +26,7 @@ from app.gateway.internal_auth import get_trusted_internal_owner_user_id
from app.gateway.utils import sanitize_log_param
from deerflow.config.paths import Paths, get_paths
from deerflow.runtime import serialize_channel_values_for_api
from deerflow.runtime.goal import DEFAULT_MAX_GOAL_CONTINUATIONS, build_goal_state, ensure_thread_checkpoint, goal_thread_lock, read_thread_goal, write_thread_goal
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.utils.time import coerce_iso, now_iso
@ -144,6 +145,24 @@ class ThreadStateUpdateRequest(BaseModel):
as_node: str | None = Field(default=None, description="Node identity for the update")
class ThreadGoalRequest(BaseModel):
"""Request body for setting a thread-scoped goal."""
objective: str = Field(..., min_length=1, max_length=4000, description="Completion condition for the agent to keep pursuing")
max_continuations: int = Field(
default=DEFAULT_MAX_GOAL_CONTINUATIONS,
ge=0,
le=DEFAULT_MAX_GOAL_CONTINUATIONS,
description="Maximum automatic hidden continuation turns before stopping",
)
class ThreadGoalResponse(BaseModel):
"""Response model for a thread goal."""
goal: dict[str, Any] | None = Field(default=None, description="Current goal state, or null when no goal is active")
class HistoryEntry(BaseModel):
"""Single checkpoint history entry."""
@ -205,6 +224,36 @@ def _derive_thread_status(checkpoint_tuple) -> str:
return "idle"
async def _ensure_thread_for_goal(thread_id: str, request: Request) -> None:
"""Ensure a thread_meta row and root checkpoint exist for goal commands."""
from app.gateway.deps import get_thread_store
thread_store = get_thread_store(request)
checkpointer = get_checkpointer(request)
thread_owner_user_id = get_trusted_internal_owner_user_id(request)
thread_owner_kwargs = {"user_id": thread_owner_user_id} if thread_owner_user_id else {}
record = await thread_store.get(thread_id, **thread_owner_kwargs)
if record is None and thread_owner_user_id:
unscoped_record = await thread_store.get(thread_id, user_id=None)
if unscoped_record is not None:
if unscoped_record.get("user_id") != thread_owner_user_id:
await thread_store.update_owner(thread_id, thread_owner_user_id, user_id=None)
record = await thread_store.get(thread_id, **thread_owner_kwargs)
if record is None:
try:
await thread_store.create(thread_id, metadata={}, **thread_owner_kwargs)
except Exception:
logger.exception("Failed to create thread_meta for goal thread %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to create thread") from None
try:
await ensure_thread_checkpoint(checkpointer, thread_id)
except Exception:
logger.exception("Failed to create goal checkpoint for thread %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to create thread checkpoint") from None
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@ -441,6 +490,57 @@ async def get_thread(thread_id: str, request: Request) -> ThreadResponse:
)
@router.get("/{thread_id}/goal", response_model=ThreadGoalResponse)
@require_permission("threads", "read", owner_check=True)
async def get_thread_goal(thread_id: str, request: Request) -> ThreadGoalResponse:
"""Return the active Claude-style goal for a thread, if any."""
checkpointer = get_checkpointer(request)
try:
goal = await read_thread_goal(checkpointer, thread_id)
except Exception:
logger.exception("Failed to read goal for thread %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to read thread goal") from None
return ThreadGoalResponse(goal=goal)
@router.put("/{thread_id}/goal", response_model=ThreadGoalResponse)
@require_permission("threads", "write", owner_check=True)
async def set_thread_goal(thread_id: str, body: ThreadGoalRequest, request: Request) -> ThreadGoalResponse:
"""Set or replace the active goal for a thread.
``/chats/new`` pages already hold a generated UUID before the first run, so
this endpoint creates the missing thread checkpoint on demand.
"""
checkpointer = get_checkpointer(request)
await _ensure_thread_for_goal(thread_id, request)
try:
goal = build_goal_state(body.objective, max_continuations=body.max_continuations)
async with goal_thread_lock(thread_id):
await write_thread_goal(checkpointer, thread_id, goal, as_node="goal", create_if_missing=True)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except Exception:
logger.exception("Failed to set goal for thread %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to set thread goal") from None
return ThreadGoalResponse(goal=goal)
@router.delete("/{thread_id}/goal", response_model=ThreadGoalResponse)
@require_permission("threads", "write", owner_check=True)
async def clear_thread_goal(thread_id: str, request: Request) -> ThreadGoalResponse:
"""Clear the active goal for a thread."""
checkpointer = get_checkpointer(request)
try:
async with goal_thread_lock(thread_id):
await write_thread_goal(checkpointer, thread_id, None, as_node="goal")
except LookupError:
return ThreadGoalResponse(goal=None)
except Exception:
logger.exception("Failed to clear goal for thread %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to clear thread goal") from None
return ThreadGoalResponse(goal=None)
# ---------------------------------------------------------------------------
@router.get("/{thread_id}/state", response_model=ThreadStateResponse)
@require_permission("threads", "read", owner_check=True)

View File

@ -60,10 +60,12 @@ instead of hanging.
### Slash commands
`/help` `/new` `/threads` (`/switch`) `/model` `/skills` `/tools` `/mcp`
`/memory` `/uploads` `/usage` `/config` `/quit`, plus `/<skill-name> task` to
activate any enabled skill for the current turn (same semantics as elsewhere in
DeerFlow). `/model` and `/threads` open modal pickers.
`/help` `/new` `/goal` `/threads` (`/switch`) `/model` `/skills` `/tools`
`/mcp` `/memory` `/uploads` `/usage` `/config` `/quit`, plus
`/<skill-name> task` to activate any enabled skill for the current turn (same
semantics as elsewhere in DeerFlow). `/model` and `/threads` open modal pickers.
Use `/goal <condition>` to set the active thread goal, `/goal` to show it, and
`/goal clear` to clear it.
## Architecture

View File

@ -0,0 +1,31 @@
from __future__ import annotations
from typing import Any, Literal, NotRequired, TypedDict
GoalBlocker = Literal[
"none",
"missing_evidence",
"needs_user_input",
"run_failed",
"external_wait",
"goal_not_met_yet",
]
class GoalEvaluation(TypedDict):
satisfied: bool
blocker: GoalBlocker
reason: str
evidence_summary: NotRequired[str]
class GoalState(TypedDict):
objective: str
status: Literal["active"]
created_at: str
updated_at: str
continuation_count: int
max_continuations: int
no_progress_count: int
max_no_progress_continuations: int
last_evaluation: NotRequired[dict[str, Any]]

View File

@ -3,6 +3,7 @@ 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
@ -85,6 +86,13 @@ def merge_todos(existing: list | None, new: list | None) -> list | None:
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]
@ -218,6 +226,7 @@ class ThreadState(AgentState):
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]

View File

@ -16,6 +16,7 @@ Usage:
"""
import asyncio
import concurrent.futures
import json
import logging
import mimetypes
@ -41,6 +42,7 @@ from deerflow.config.app_config import get_app_config, is_trace_correlation_enab
from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
from deerflow.config.paths import get_paths
from deerflow.models import create_chat_model
from deerflow.runtime.goal import DEFAULT_MAX_GOAL_CONTINUATIONS, build_goal_state, goal_thread_lock, read_thread_goal, write_thread_goal
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.skills.storage import get_or_new_skill_storage
from deerflow.tools.builtins.tool_search import assemble_deferred_tools
@ -60,6 +62,19 @@ from deerflow.uploads.manager import (
logger = logging.getLogger(__name__)
def _run_async_from_sync(coro):
"""Run an async helper from this synchronous client API."""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop is not None and loop.is_running():
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
return executor.submit(asyncio.run, coro).result()
return asyncio.run(coro)
StreamEventType = Literal["values", "messages-tuple", "custom", "end"]
@ -411,6 +426,52 @@ class DeerFlowClient:
# Public API — threads
# ------------------------------------------------------------------
def _get_thread_checkpointer(self):
checkpointer = self._checkpointer
if checkpointer is None:
from deerflow.runtime.checkpointer.provider import get_checkpointer
checkpointer = get_checkpointer()
return checkpointer
def get_goal(self, thread_id: str) -> dict:
"""Return the active goal for a thread, if any."""
checkpointer = self._get_thread_checkpointer()
goal = _run_async_from_sync(read_thread_goal(checkpointer, thread_id))
return {"goal": goal}
def set_goal(
self,
thread_id: str,
objective: str,
*,
max_continuations: int = DEFAULT_MAX_GOAL_CONTINUATIONS,
) -> dict:
"""Set or replace a thread-scoped goal."""
checkpointer = self._get_thread_checkpointer()
goal = build_goal_state(objective, max_continuations=max_continuations)
async def _set_goal() -> None:
async with goal_thread_lock(thread_id):
await write_thread_goal(checkpointer, thread_id, goal, create_if_missing=True)
_run_async_from_sync(_set_goal())
return {"goal": goal}
def clear_goal(self, thread_id: str) -> dict:
"""Clear the active goal for a thread."""
checkpointer = self._get_thread_checkpointer()
async def _clear_goal() -> None:
async with goal_thread_lock(thread_id):
await write_thread_goal(checkpointer, thread_id, None)
try:
_run_async_from_sync(_clear_goal())
except LookupError:
pass
return {"goal": None}
def list_threads(self, limit: int = 10) -> dict:
"""List the recent N threads.
@ -421,11 +482,7 @@ class DeerFlowClient:
Dict with "thread_list" key containing list of thread info dicts,
sorted by thread creation time descending.
"""
checkpointer = self._checkpointer
if checkpointer is None:
from deerflow.runtime.checkpointer.provider import get_checkpointer
checkpointer = get_checkpointer()
checkpointer = self._get_thread_checkpointer()
thread_info_map = {}
@ -476,11 +533,7 @@ class DeerFlowClient:
Returns:
Dict containing the thread's full checkpoint history.
"""
checkpointer = self._checkpointer
if checkpointer is None:
from deerflow.runtime.checkpointer.provider import get_checkpointer
checkpointer = get_checkpointer()
checkpointer = self._get_thread_checkpointer()
config = {"configurable": {"thread_id": thread_id}}
checkpoints = []

View File

@ -0,0 +1,522 @@
"""Thread-scoped goal state and evaluator helpers.
This module implements the Claude Code-style goal loop primitives used by
Gateway runs and thin API surfaces. It intentionally lives in ``deerflow`` so
the harness can evaluate and continue runs without importing the FastAPI app.
"""
from __future__ import annotations
import asyncio
import copy
import hashlib
import inspect
import json
import logging
import threading
import weakref
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any, Literal, NamedTuple
from langchain_core.messages import HumanMessage, SystemMessage
from langgraph.checkpoint.base import empty_checkpoint, uuid6
import deerflow.utils.llm_text as llm_text
from deerflow.agents.goal_state import GoalBlocker, GoalEvaluation, GoalState
from deerflow.models import create_chat_model
from deerflow.utils.messages import message_to_text
from deerflow.utils.time import now_iso
logger = logging.getLogger(__name__)
DEFAULT_MAX_GOAL_CONTINUATIONS = 8
DEFAULT_MAX_NO_PROGRESS_CONTINUATIONS = 2
MAX_GOAL_OBJECTIVE_CHARS = 4000
MAX_GOAL_REASON_CHARS = 1000
MAX_GOAL_EVIDENCE_CHARS = 1000
MAX_GOAL_CONVERSATION_CHARS = 12000
MAX_GOAL_CONVERSATION_MESSAGES = 30
GOAL_BLOCKERS: set[GoalBlocker] = {
"none",
"missing_evidence",
"needs_user_input",
"run_failed",
"external_wait",
"goal_not_met_yet",
}
CONTINUABLE_GOAL_BLOCKERS: set[GoalBlocker] = {"goal_not_met_yet"}
GOAL_CLEAR_ALIASES = frozenset({"clear", "reset", "off"})
_extract_response_text = llm_text.extract_response_text
_strip_markdown_code_fence = llm_text.strip_markdown_code_fence
_strip_think_blocks = llm_text.strip_think_blocks
_goal_locks_guard = threading.Lock()
_goal_locks_by_loop: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, dict[str, asyncio.Lock]] = weakref.WeakKeyDictionary()
class GoalWriteConflict(RuntimeError):
"""Raised when a goal write is based on a stale checkpoint."""
@asynccontextmanager
async def goal_thread_lock(thread_id: str) -> AsyncIterator[None]:
"""Serialize goal read-modify-write sequences within the current event loop."""
loop = asyncio.get_running_loop()
with _goal_locks_guard:
locks = _goal_locks_by_loop.get(loop)
if locks is None:
locks = {}
_goal_locks_by_loop[loop] = locks
lock = locks.get(thread_id)
if lock is None:
lock = asyncio.Lock()
locks[thread_id] = lock
async with lock:
yield
class GoalCommand(NamedTuple):
"""Parsed intent of a ``/goal`` slash command argument string."""
kind: Literal["status", "clear", "set"]
objective: str = ""
def parse_goal_command(args: str) -> GoalCommand:
"""Parse the argument string of a ``/goal`` command into an intent.
Shared by the TUI and IM-channel surfaces so the three-way semantics stay in
one place: empty shows the active goal, ``clear``/``reset``/``off`` clears it,
and anything else sets the goal to that (trimmed) objective. The frontend
keeps a parallel TypeScript copy in ``input-box-helpers.ts``.
"""
stripped = args.strip()
if not stripped:
return GoalCommand("status")
if stripped.lower() in GOAL_CLEAR_ALIASES:
return GoalCommand("clear")
return GoalCommand("set", stripped)
def normalize_goal_objective(objective: str) -> str:
"""Normalize and validate user-provided goal text."""
normalized = " ".join(objective.strip().split())
if not normalized:
raise ValueError("Goal objective must not be empty.")
if len(normalized) > MAX_GOAL_OBJECTIVE_CHARS:
raise ValueError(f"Goal objective must be at most {MAX_GOAL_OBJECTIVE_CHARS} characters.")
return normalized
def build_goal_state(
objective: str,
*,
max_continuations: int = DEFAULT_MAX_GOAL_CONTINUATIONS,
max_no_progress_continuations: int = DEFAULT_MAX_NO_PROGRESS_CONTINUATIONS,
now: str | None = None,
) -> GoalState:
"""Create a fresh active goal state for a thread."""
objective = normalize_goal_objective(objective)
capped_max = max(0, min(int(max_continuations), DEFAULT_MAX_GOAL_CONTINUATIONS))
timestamp = now or now_iso()
return GoalState(
objective=objective,
status="active",
created_at=timestamp,
updated_at=timestamp,
continuation_count=0,
max_continuations=capped_max,
no_progress_count=0,
max_no_progress_continuations=max(0, int(max_no_progress_continuations)),
)
def parse_goal_evaluation_response(text: str) -> GoalEvaluation:
"""Parse the evaluator's JSON object response."""
candidate = _strip_markdown_code_fence(_strip_think_blocks(text))
start = candidate.find("{")
end = candidate.rfind("}")
if start == -1 or end == -1 or end <= start:
raise ValueError("Goal evaluator response did not contain a JSON object.")
try:
payload = json.loads(candidate[start : end + 1])
except Exception as exc:
raise ValueError("Goal evaluator response was not valid JSON.") from exc
if not isinstance(payload, dict):
raise ValueError("Goal evaluator JSON must be an object.")
satisfied = payload.get("satisfied")
if not isinstance(satisfied, bool):
raise ValueError("Goal evaluator JSON must include boolean 'satisfied'.")
reason = _normalize_evaluation_text(payload.get("reason"), max_chars=MAX_GOAL_REASON_CHARS)
evidence_summary = _normalize_evaluation_text(payload.get("evidence_summary"), max_chars=MAX_GOAL_EVIDENCE_CHARS)
blocker = _normalize_goal_blocker(payload.get("blocker"), satisfied=satisfied)
return GoalEvaluation(
satisfied=satisfied,
blocker=blocker,
reason=reason,
evidence_summary=evidence_summary,
)
def _normalize_evaluation_text(value: object, *, max_chars: int) -> str:
if not isinstance(value, str):
return ""
return " ".join(value.strip().split())[:max_chars]
def _normalize_goal_blocker(value: object, *, satisfied: bool) -> GoalBlocker:
if satisfied:
return "none"
if isinstance(value, str) and value in GOAL_BLOCKERS and value != "none":
return value
return "missing_evidence"
def _message_type(message: Any) -> str | None:
value = getattr(message, "type", None)
if value is None and isinstance(message, dict):
value = message.get("type") or message.get("role")
if value == "assistant":
return "ai"
if value == "user":
return "human"
return str(value) if value else None
def _additional_kwargs(message: Any) -> dict[str, Any]:
value = getattr(message, "additional_kwargs", None)
if value is None and isinstance(message, dict):
value = message.get("additional_kwargs")
return dict(value) if isinstance(value, dict) else {}
def _is_visible_message(message: Any) -> bool:
if _additional_kwargs(message).get("hide_from_ui") is True:
return False
return _message_type(message) in {"human", "ai"}
def has_visible_assistant_evidence(messages: list[Any]) -> bool:
"""Return true when the evaluator can inspect at least one visible AI reply."""
return any(_is_visible_message(message) and _message_type(message) == "ai" and bool(message_to_text(message).strip()) for message in messages)
def visible_conversation_signature(messages: list[Any]) -> str:
"""Return a stable lightweight signature for the visible evaluator evidence."""
visible = []
for message in messages:
if not _is_visible_message(message):
continue
visible.append(
{
"role": _message_type(message),
"text": message_to_text(message).strip(),
}
)
return json.dumps(visible[-MAX_GOAL_CONVERSATION_MESSAGES:], ensure_ascii=False, sort_keys=True)
def format_visible_conversation(messages: list[Any]) -> str:
"""Return the user-visible conversation evidence for goal evaluation."""
lines: list[str] = []
visible = [message for message in messages if _is_visible_message(message)]
for message in visible[-MAX_GOAL_CONVERSATION_MESSAGES:]:
text = message_to_text(message).strip()
if not text:
continue
role = "User" if _message_type(message) == "human" else "Assistant"
lines.append(f"{role}: {text}")
conversation = "\n\n".join(lines)
if len(conversation) > MAX_GOAL_CONVERSATION_CHARS:
conversation = conversation[-MAX_GOAL_CONVERSATION_CHARS:]
return conversation
def create_goal_evaluator_model(
*,
model_name: str | None = None,
app_config: Any | None = None,
) -> Any:
"""Create the non-thinking chat model used by the goal evaluator."""
return create_chat_model(
name=model_name,
thinking_enabled=False,
app_config=app_config,
attach_tracing=False,
)
async def evaluate_goal_completion(
goal: GoalState,
messages: list[Any],
*,
model: Any | None = None,
model_name: str | None = None,
app_config: Any | None = None,
) -> GoalEvaluation:
"""Ask a small non-thinking model whether the active goal is satisfied."""
conversation = format_visible_conversation(messages)
if not conversation or not has_visible_assistant_evidence(messages):
return GoalEvaluation(
satisfied=False,
blocker="missing_evidence",
reason="No visible assistant evidence is available yet.",
evidence_summary="",
)
system_instruction = (
"You are a strict completion evaluator for an AI coding assistant.\n"
"Decide whether the active goal is fully satisfied using ONLY the visible conversation evidence.\n"
"Do not assume files, commands, tests, or external state changed unless the conversation explicitly shows it.\n"
"If the visible evidence is too weak to prove progress, fail closed with blocker missing_evidence.\n"
"Use blocker needs_user_input when the assistant is waiting on the user, run_failed when the turn failed, "
"external_wait when work is waiting on an outside system, goal_not_met_yet when useful autonomous work can continue, "
"and none only when satisfied is true.\n"
'Output exactly one JSON object: {"satisfied": boolean, "blocker": string, "reason": string, "evidence_summary": string}.'
)
user_content = f"Active goal:\n{goal['objective']}\n\nVisible conversation evidence:\n{conversation}\n\nIs the active goal fully satisfied?"
if model is None:
model = create_goal_evaluator_model(model_name=model_name, app_config=app_config)
response = await model.ainvoke(
[SystemMessage(content=system_instruction), HumanMessage(content=user_content)],
config={"run_name": "goal_evaluator"},
)
return parse_goal_evaluation_response(_extract_response_text(response.content))
def should_continue_goal(goal: GoalState, evaluation: GoalEvaluation, *, no_progress_count: int | None = None) -> bool:
"""Return whether another hidden continuation turn should run."""
if evaluation["satisfied"]:
return False
if evaluation["blocker"] not in CONTINUABLE_GOAL_BLOCKERS:
return False
if int(goal.get("continuation_count", 0)) >= int(goal.get("max_continuations", DEFAULT_MAX_GOAL_CONTINUATIONS)):
return False
current_no_progress = int(goal.get("no_progress_count", 0) if no_progress_count is None else no_progress_count)
max_no_progress = int(goal.get("max_no_progress_continuations", DEFAULT_MAX_NO_PROGRESS_CONTINUATIONS))
return current_no_progress < max_no_progress
def latest_visible_assistant_signature(messages: list[Any]) -> str:
"""Return a stable signature of the latest visible assistant evidence.
The "no progress" breaker keys on what the agent actually produced the
text of the most recent user-visible assistant message not on the
evaluator's free-text ``reason``/``evidence_summary`` (which an LLM rewords
on every turn, so it almost never repeats byte-for-byte). When a
continuation adds no new visible assistant output, the signature is
unchanged and the breaker can recognise the stalled turn.
"""
for message in reversed(messages):
if not _is_visible_message(message) or _message_type(message) != "ai":
continue
text = message_to_text(message).strip()
if text:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
return ""
def compute_goal_progress_key(evaluation: GoalEvaluation, *, evidence_signature: str = "") -> str:
"""Return a stable key used to detect repeated non-progress evaluations.
Keyed on the typed ``blocker`` plus a signature of the visible assistant
evidence, so a stalled goal is detected even when the evaluator rewords its
free-text ``reason``/``evidence_summary``.
"""
return json.dumps(
{
"satisfied": evaluation["satisfied"],
"blocker": evaluation["blocker"],
"evidence_signature": evidence_signature,
},
ensure_ascii=False,
sort_keys=True,
)
def compute_no_progress_count(goal: GoalState, evaluation: GoalEvaluation, *, evidence_signature: str = "") -> int:
"""Increment repeated-progress count when visible evidence has not advanced."""
if evaluation["satisfied"]:
return 0
progress_key = compute_goal_progress_key(evaluation, evidence_signature=evidence_signature)
previous = goal.get("last_evaluation", {})
if isinstance(previous, dict) and previous.get("progress_key") == progress_key:
return int(goal.get("no_progress_count", 0)) + 1
return 0
def make_goal_continuation_message(goal: GoalState, evaluation: GoalEvaluation) -> HumanMessage:
"""Build the hidden user message that asks the agent to keep working."""
content = (
"<goal_continuation>\n"
f"Active goal: {goal['objective']}\n"
f"Evaluator result: not satisfied. Blocker: {evaluation['blocker']}. Reason: {evaluation['reason'] or 'No reason provided.'}\n"
f"Visible evidence: {evaluation.get('evidence_summary') or 'No evidence summary provided.'}\n"
"Continue working toward the active goal. Use the available tools and conversation context. "
"Do not ask the user to continue unless you are genuinely blocked.\n"
"</goal_continuation>"
)
return HumanMessage(
content=content,
additional_kwargs={
"hide_from_ui": True,
"deerflow_goal_continuation": True,
},
)
async def _call_checkpointer_method(checkpointer: Any, async_name: str, sync_name: str, *args: Any, **kwargs: Any) -> Any:
async_method = getattr(checkpointer, async_name, None)
if async_method is not None:
result = async_method(*args, **kwargs)
return await result if inspect.isawaitable(result) else result
sync_method = getattr(checkpointer, sync_name, None)
if sync_method is None:
raise AttributeError(f"Missing checkpointer method: {async_name}/{sync_name}")
# Offload the synchronous checkpointer call so its blocking IO never runs on
# the event loop (backend/AGENTS.md blocking-IO gate).
result = await asyncio.to_thread(sync_method, *args, **kwargs)
return await result if inspect.isawaitable(result) else result
async def ensure_thread_checkpoint(checkpointer: Any, thread_id: str) -> None:
"""Create an empty root checkpoint for *thread_id* when none exists."""
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
checkpoint_tuple = await _call_checkpointer_method(checkpointer, "aget_tuple", "get_tuple", config)
if checkpoint_tuple is not None:
return
metadata = {
"step": -1,
"source": "input",
"writes": None,
"parents": {},
"created_at": now_iso(),
}
await _call_checkpointer_method(checkpointer, "aput", "put", config, empty_checkpoint(), metadata, {})
def _checkpoint_id_from_tuple(checkpoint_tuple: Any) -> str | None:
config = getattr(checkpoint_tuple, "config", {}) or {}
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
checkpoint_id = configurable.get("checkpoint_id") if isinstance(configurable, dict) else None
if isinstance(checkpoint_id, str):
return checkpoint_id
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
if isinstance(checkpoint, dict) and isinstance(checkpoint.get("id"), str):
return checkpoint["id"]
return None
async def read_thread_goal(checkpointer: Any, thread_id: str) -> GoalState | None:
"""Read the latest thread goal from checkpoint state."""
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
checkpoint_tuple = await _call_checkpointer_method(checkpointer, "aget_tuple", "get_tuple", config)
if checkpoint_tuple is None:
return None
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
channel_values = checkpoint.get("channel_values", {}) if isinstance(checkpoint, dict) else {}
raw_goal = channel_values.get("goal") if isinstance(channel_values, dict) else None
return copy.deepcopy(raw_goal) if isinstance(raw_goal, dict) else None
async def write_thread_goal(
checkpointer: Any,
thread_id: str,
goal: GoalState | None,
*,
as_node: str = "goal",
create_if_missing: bool = False,
expected_checkpoint_id: str | None = None,
) -> dict[str, Any]:
"""Write a new checkpoint with the thread goal set or cleared.
Returns the updated channel values.
"""
if create_if_missing:
await ensure_thread_checkpoint(checkpointer, thread_id)
read_config: dict[str, Any] = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": "",
}
}
checkpoint_tuple = await _call_checkpointer_method(checkpointer, "aget_tuple", "get_tuple", read_config)
if checkpoint_tuple is None:
raise LookupError(f"Thread {thread_id} checkpoint not found")
if expected_checkpoint_id is not None and _checkpoint_id_from_tuple(checkpoint_tuple) != expected_checkpoint_id:
raise GoalWriteConflict(f"Thread {thread_id} goal checkpoint changed while preparing write")
checkpoint: dict[str, Any] = dict(getattr(checkpoint_tuple, "checkpoint", {}) or {})
metadata: dict[str, Any] = dict(getattr(checkpoint_tuple, "metadata", {}) or {})
channel_values: dict[str, Any] = dict(checkpoint.get("channel_values", {}) or {})
if goal is None:
channel_values.pop("goal", None)
else:
channel_values["goal"] = copy.deepcopy(goal)
channel_versions = dict(checkpoint.get("channel_versions", {}) or {})
current_version = channel_versions.get("goal")
get_next_version = getattr(checkpointer, "get_next_version", None)
if callable(get_next_version):
next_version = get_next_version(current_version, None)
elif isinstance(current_version, int):
next_version = current_version + 1
else:
next_version = 1
channel_versions["goal"] = next_version
checkpoint["channel_values"] = channel_values
checkpoint["channel_versions"] = channel_versions
checkpoint["id"] = str(uuid6())
metadata["updated_at"] = now_iso()
metadata["source"] = "update"
metadata["step"] = metadata.get("step", 0) + 1
metadata["writes"] = {as_node: {"goal": goal}}
write_config = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": "",
}
}
await _call_checkpointer_method(checkpointer, "aput", "put", write_config, checkpoint, metadata, {"goal": next_version})
return channel_values
def attach_goal_evaluation(
goal: GoalState,
evaluation: GoalEvaluation,
*,
run_id: str,
continuation_count: int | None = None,
no_progress_count: int | None = None,
stand_down_reason: str | None = None,
evidence_signature: str = "",
) -> GoalState:
"""Return a goal copy with the latest evaluator result attached."""
next_goal = copy.deepcopy(goal)
if continuation_count is not None:
next_goal["continuation_count"] = continuation_count
if no_progress_count is not None:
next_goal["no_progress_count"] = no_progress_count
next_goal["updated_at"] = now_iso()
next_goal["last_evaluation"] = {
"satisfied": evaluation["satisfied"],
"blocker": evaluation["blocker"],
"reason": evaluation["reason"],
"evidence_summary": evaluation.get("evidence_summary", ""),
"run_id": run_id,
"evaluated_at": next_goal["updated_at"],
"progress_key": compute_goal_progress_key(evaluation, evidence_signature=evidence_signature),
}
if stand_down_reason:
next_goal["last_evaluation"]["stand_down_reason"] = stand_down_reason
return next_goal

View File

@ -26,12 +26,33 @@ from typing import Any, Literal, cast
from langgraph.checkpoint.base import empty_checkpoint
from deerflow.agents.goal_state import GoalEvaluation, GoalState
from deerflow.config.app_config import AppConfig
from deerflow.runtime.goal import (
DEFAULT_MAX_GOAL_CONTINUATIONS,
DEFAULT_MAX_NO_PROGRESS_CONTINUATIONS,
GoalWriteConflict,
_call_checkpointer_method,
_is_visible_message,
_message_type,
attach_goal_evaluation,
compute_no_progress_count,
create_goal_evaluator_model,
evaluate_goal_completion,
goal_thread_lock,
latest_visible_assistant_signature,
make_goal_continuation_message,
read_thread_goal,
should_continue_goal,
visible_conversation_signature,
write_thread_goal,
)
from deerflow.runtime.serialization import serialize
from deerflow.runtime.stream_bridge import StreamBridge
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.trace_context import DEERFLOW_TRACE_METADATA_KEY, get_current_trace_id, normalize_trace_id
from deerflow.tracing import inject_langfuse_metadata
from deerflow.utils.messages import message_to_text
from .manager import RunManager, RunRecord
from .naming import resolve_root_run_name
@ -320,11 +341,21 @@ async def run_agent(
# Resolve after runtime context installation so context/configurable reflect
# the agent name that this run will actually execute.
config.setdefault("run_name", resolve_root_run_name(config, record.assistant_id))
runnable_config = RunnableConfig(**config)
initial_runnable_config = RunnableConfig(**config)
def _continuation_runnable_config() -> RunnableConfig:
continuation_config = dict(config)
configurable = dict(continuation_config.get("configurable", {}) or {})
configurable["checkpoint_ns"] = ""
configurable.pop("checkpoint_id", None)
configurable.pop("checkpoint_map", None)
continuation_config["configurable"] = configurable
return RunnableConfig(**continuation_config)
if ctx.app_config is not None and _agent_factory_supports_app_config(agent_factory):
agent = agent_factory(config=runnable_config, app_config=ctx.app_config)
agent = agent_factory(config=initial_runnable_config, app_config=ctx.app_config)
else:
agent = agent_factory(config=runnable_config)
agent = agent_factory(config=initial_runnable_config)
# Capture the effective (resolved) model name from the agent's metadata.
# _resolve_model_name in agent.py may return the default model if the
@ -380,24 +411,37 @@ async def run_agent(
# the finally block so buffered steps survive abort/exception paths too.
subagent_events = _SubagentEventBuffer(event_store, thread_id, run_id)
# 7. Stream using graph.astream
if len(lg_modes) == 1 and not stream_subgraphs:
# Single mode, no subgraphs: astream yields raw chunks
single_mode = lg_modes[0]
async for chunk in agent.astream(graph_input, config=runnable_config, stream_mode=single_mode):
if record.abort_event.is_set():
logger.info("Run %s abort requested — stopping", run_id)
break
llm_error_fallback_message = llm_error_fallback_message or _extract_llm_error_fallback_message(chunk)
sse_event = _lg_mode_to_sse_event(single_mode)
await bridge.publish(run_id, sse_event, serialize(chunk, mode=single_mode))
if single_mode == "custom":
await subagent_events.add(chunk)
else:
goal_evaluator_model: Any | None = None
def _get_goal_evaluator_model() -> Any:
nonlocal goal_evaluator_model
if goal_evaluator_model is None:
goal_evaluator_model = create_goal_evaluator_model(
model_name=record.model_name,
app_config=ctx.app_config,
)
return goal_evaluator_model
async def _stream_once(input_payload: Any, stream_config: RunnableConfig) -> None:
nonlocal llm_error_fallback_message
if len(lg_modes) == 1 and not stream_subgraphs:
# Single mode, no subgraphs: astream yields raw chunks
single_mode = lg_modes[0]
async for chunk in agent.astream(input_payload, config=stream_config, stream_mode=single_mode):
if record.abort_event.is_set():
logger.info("Run %s abort requested — stopping", run_id)
break
llm_error_fallback_message = llm_error_fallback_message or _extract_llm_error_fallback_message(chunk)
sse_event = _lg_mode_to_sse_event(single_mode)
await bridge.publish(run_id, sse_event, serialize(chunk, mode=single_mode))
if single_mode == "custom":
await subagent_events.add(chunk)
return
# Multiple modes or subgraphs: astream yields tuples
async for item in agent.astream(
graph_input,
config=runnable_config,
input_payload,
config=stream_config,
stream_mode=lg_modes,
subgraphs=stream_subgraphs,
):
@ -415,6 +459,23 @@ async def run_agent(
if mode == "custom":
await subagent_events.add(chunk)
# 7. Stream the requested turn, then optionally continue hidden goal turns.
await _stream_once(graph_input, initial_runnable_config)
while not record.abort_event.is_set() and not llm_error_fallback_message and (journal is None or not journal.had_llm_error_fallback):
continuation_input = await _prepare_goal_continuation_input(
bridge=bridge,
checkpointer=checkpointer,
thread_id=thread_id,
run_id=run_id,
model_name=record.model_name,
app_config=ctx.app_config,
evaluator_model_factory=_get_goal_evaluator_model,
abort_event=record.abort_event,
)
if continuation_input is None or record.abort_event.is_set():
break
await _stream_once(continuation_input, _continuation_runnable_config())
# 8. Final status
if record.abort_event.is_set():
await run_manager.set_finalizing(run_id, True)
@ -539,15 +600,317 @@ async def run_agent(
# ---------------------------------------------------------------------------
async def _call_checkpointer_method(checkpointer: Any, async_name: str, sync_name: str, *args: Any, **kwargs: Any) -> Any:
"""Call a checkpointer method, supporting async and sync variants."""
method = getattr(checkpointer, async_name, None) or getattr(checkpointer, sync_name, None)
if method is None:
raise AttributeError(f"Missing checkpointer method: {async_name}/{sync_name}")
result = method(*args, **kwargs)
if inspect.isawaitable(result):
return await result
return result
def _checkpoint_id(checkpoint_tuple: Any) -> str | None:
config = getattr(checkpoint_tuple, "config", {}) or {}
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
checkpoint_id = configurable.get("checkpoint_id") if isinstance(configurable, dict) else None
if isinstance(checkpoint_id, str):
return checkpoint_id
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
if isinstance(checkpoint, dict) and isinstance(checkpoint.get("id"), str):
return checkpoint["id"]
return None
def _goal_instance_matches(left: GoalState | None, right: GoalState | None) -> bool:
if not left or not right:
return False
same_status = left.get("status") == right.get("status") == "active"
same_objective = left.get("objective") == right.get("objective")
same_created_at = left.get("created_at") == right.get("created_at")
return same_status and same_objective and same_created_at
def _read_checkpoint_messages(checkpoint_tuple: Any) -> list[Any]:
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
channel_values = checkpoint.get("channel_values", {}) if isinstance(checkpoint, dict) else {}
messages = channel_values.get("messages", []) if isinstance(channel_values, dict) else []
return messages if isinstance(messages, list) else []
def _read_checkpoint_goal(checkpoint_tuple: Any) -> GoalState | None:
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
channel_values = checkpoint.get("channel_values", {}) if isinstance(checkpoint, dict) else {}
raw_goal = channel_values.get("goal") if isinstance(channel_values, dict) else None
return copy.deepcopy(raw_goal) if isinstance(raw_goal, dict) else None
def _has_durable_goal_turn_receipt(checkpoint_tuple: Any, messages: list[Any]) -> bool:
"""Return true when a completed visible assistant turn is safely checkpointed.
``pending_writes`` is the durability signal: a ``CheckpointTuple`` carries no
``tasks`` field (those live on a ``StateSnapshot``), so the presence of any
queued writes is what tells us the turn is still in flight.
"""
if _checkpoint_id(checkpoint_tuple) is None:
return False
if getattr(checkpoint_tuple, "pending_writes", None):
return False
visible_messages = []
for message in messages:
if _is_visible_message(message) and message_to_text(message).strip():
visible_messages.append(message)
if not visible_messages:
return False
return _message_type(visible_messages[-1]) == "ai"
def _stand_down_reason(goal: GoalState, evaluation: GoalEvaluation, no_progress_count: int) -> str | None:
if evaluation["satisfied"]:
return None
if evaluation["blocker"] != "goal_not_met_yet":
return f"blocked:{evaluation['blocker']}"
# Default caps mirror should_continue_goal so the two gate functions agree on
# a goal dict that is missing these fields.
if int(goal.get("continuation_count", 0)) >= int(goal.get("max_continuations", DEFAULT_MAX_GOAL_CONTINUATIONS)):
return "max_continuations_reached"
if no_progress_count >= int(goal.get("max_no_progress_continuations", DEFAULT_MAX_NO_PROGRESS_CONTINUATIONS)):
return "no_progress_detected"
return None
async def _persist_goal_evaluation(
*,
bridge: StreamBridge,
checkpointer: Any,
thread_id: str,
run_id: str,
goal: GoalState,
evaluation: GoalEvaluation,
no_progress_count: int,
continuation_count: int | None = None,
stand_down_reason: str | None = None,
evidence_signature: str = "",
) -> GoalState | None:
try:
async with goal_thread_lock(thread_id):
checkpoint_tuple = await _call_checkpointer_method(
checkpointer,
"aget_tuple",
"get_tuple",
{"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}},
)
if checkpoint_tuple is None:
return None
current_goal = _read_checkpoint_goal(checkpoint_tuple)
if current_goal is None or not _goal_instance_matches(goal, current_goal):
return None
expected_checkpoint_id = _checkpoint_id(checkpoint_tuple)
updated_goal = attach_goal_evaluation(
current_goal,
evaluation,
run_id=run_id,
continuation_count=continuation_count,
no_progress_count=no_progress_count,
stand_down_reason=stand_down_reason,
evidence_signature=evidence_signature,
)
values = await write_thread_goal(
checkpointer,
thread_id,
updated_goal,
as_node="goal_evaluator",
expected_checkpoint_id=expected_checkpoint_id,
)
await bridge.publish(run_id, "values", serialize(values, mode="values"))
return updated_goal
except GoalWriteConflict:
return None
except Exception:
logger.warning("Could not persist goal evaluation for thread %s", thread_id, exc_info=True)
return None
async def _reread_goal_and_checkpoint(checkpointer: Any, thread_id: str) -> tuple[GoalState | None, Any]:
"""Re-read the goal and latest checkpoint together for a concurrency re-check."""
goal = await read_thread_goal(checkpointer, thread_id)
checkpoint_tuple = await _call_checkpointer_method(
checkpointer,
"aget_tuple",
"get_tuple",
{"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}},
)
return goal, checkpoint_tuple
async def _prepare_goal_continuation_input(
*,
bridge: StreamBridge,
checkpointer: Any,
thread_id: str,
run_id: str,
model_name: str | None,
app_config: AppConfig | None,
evaluator_model_factory: Any | None = None,
abort_event: asyncio.Event | None = None,
) -> dict[str, Any] | None:
"""Evaluate the active goal and return a hidden continuation input if needed.
NOTE: The re-reads below catch a racing user message or ``/goal clear``
before we queue a continuation. Goal writes then serialize per thread and
pass the checkpoint id they read from, so stale evaluator writes stand down
instead of clobbering a newer goal change.
"""
if checkpointer is None:
return None
if abort_event is not None and abort_event.is_set():
return None
try:
goal = await read_thread_goal(checkpointer, thread_id)
except Exception:
logger.warning("Could not read goal for thread %s after run %s", thread_id, run_id, exc_info=True)
return None
if not goal or goal.get("status") != "active":
return None
async def _persist(
goal: GoalState,
evaluation: GoalEvaluation,
no_progress_count: int,
*,
stand_down_reason: str | None = None,
continuation_count: int | None = None,
) -> GoalState | None:
"""Record the evaluation against the still-current goal instance."""
return await _persist_goal_evaluation(
bridge=bridge,
checkpointer=checkpointer,
thread_id=thread_id,
run_id=run_id,
goal=goal,
evaluation=evaluation,
no_progress_count=no_progress_count,
continuation_count=continuation_count,
stand_down_reason=stand_down_reason,
evidence_signature=evidence_signature,
)
try:
checkpoint_tuple = await _call_checkpointer_method(
checkpointer,
"aget_tuple",
"get_tuple",
{"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}},
)
if checkpoint_tuple is None:
return None
checkpoint_id_before = _checkpoint_id(checkpoint_tuple)
messages = _read_checkpoint_messages(checkpoint_tuple)
conversation_signature_before = visible_conversation_signature(messages)
evidence_signature = latest_visible_assistant_signature(messages)
if not _has_durable_goal_turn_receipt(checkpoint_tuple, messages):
evaluation = GoalEvaluation(
satisfied=False,
blocker="run_failed",
reason="No durable assistant end-of-turn receipt was available.",
evidence_summary="",
)
no_progress_count = compute_no_progress_count(goal, evaluation, evidence_signature=evidence_signature)
await _persist(goal, evaluation, no_progress_count, stand_down_reason="no_durable_end_of_turn")
return None
if abort_event is not None and abort_event.is_set():
return None
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,
)
if abort_event is not None and abort_event.is_set():
return None
except Exception:
logger.warning("Goal evaluator failed for thread %s after run %s", thread_id, run_id, exc_info=True)
return None
no_progress_count = compute_no_progress_count(goal, evaluation, evidence_signature=evidence_signature)
# Re-check that neither the goal nor the visible conversation changed while the
# evaluator ran — a user message or /goal clear racing the evaluation must win.
try:
current_goal, current_checkpoint_tuple = await _reread_goal_and_checkpoint(checkpointer, thread_id)
except Exception:
logger.warning("Could not re-check goal state for thread %s after evaluation", thread_id, exc_info=True)
return None
if not _goal_instance_matches(goal, current_goal) or current_checkpoint_tuple is None:
return None
checkpoint_changed = _checkpoint_id(current_checkpoint_tuple) != checkpoint_id_before
messages_changed = visible_conversation_signature(_read_checkpoint_messages(current_checkpoint_tuple)) != conversation_signature_before
if checkpoint_changed or messages_changed:
await _persist(current_goal, evaluation, no_progress_count, stand_down_reason="thread_changed_after_evaluation")
return None
if evaluation["satisfied"]:
try:
async with goal_thread_lock(thread_id):
latest_checkpoint_tuple = await _call_checkpointer_method(
checkpointer,
"aget_tuple",
"get_tuple",
{"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}},
)
if latest_checkpoint_tuple is None:
return None
latest_goal = _read_checkpoint_goal(latest_checkpoint_tuple)
if latest_goal is None or not _goal_instance_matches(goal, latest_goal):
return None
values = await write_thread_goal(
checkpointer,
thread_id,
None,
as_node="goal_evaluator",
expected_checkpoint_id=_checkpoint_id(latest_checkpoint_tuple),
)
await bridge.publish(run_id, "values", serialize(values, mode="values"))
except GoalWriteConflict:
return None
except Exception:
logger.warning("Could not clear satisfied goal for thread %s", thread_id, exc_info=True)
return None
stand_down_reason = _stand_down_reason(goal, evaluation, no_progress_count)
if stand_down_reason is not None or not should_continue_goal(goal, evaluation, no_progress_count=no_progress_count):
await _persist(goal, evaluation, no_progress_count, stand_down_reason=stand_down_reason)
return None
next_count = int(goal.get("continuation_count", 0)) + 1
updated_goal = await _persist(goal, evaluation, no_progress_count, continuation_count=next_count)
if updated_goal is None:
return None
# Final guard: the persist above bumped the checkpoint id, so only the visible
# conversation signature is meaningful for detecting a racing user turn here.
try:
latest_goal, latest_checkpoint_tuple = await _reread_goal_and_checkpoint(checkpointer, thread_id)
except Exception:
logger.warning("Could not verify queued goal continuation for thread %s", thread_id, exc_info=True)
return None
if not _goal_instance_matches(updated_goal, latest_goal) or latest_checkpoint_tuple is None:
return None
if visible_conversation_signature(_read_checkpoint_messages(latest_checkpoint_tuple)) != conversation_signature_before:
await _persist(
latest_goal,
evaluation,
no_progress_count,
continuation_count=next_count,
stand_down_reason="thread_changed_before_continuation",
)
return None
logger.info(
"Run %s continuing thread %s for active goal (%d/%d)",
run_id,
thread_id,
updated_goal.get("continuation_count", next_count),
updated_goal.get("max_continuations", 0),
)
return {"messages": [make_goal_continuation_message(updated_goal, evaluation)]}
async def _rollback_to_pre_run_checkpoint(

View File

@ -5,7 +5,7 @@ from dataclasses import dataclass
from deerflow.skills.types import Skill
RESERVED_SLASH_SKILL_NAMES = frozenset({"bootstrap", "help", "memory", "models", "new", "status"})
RESERVED_SLASH_SKILL_NAMES = frozenset({"bootstrap", "goal", "help", "memory", "models", "new", "status"})
_SLASH_SKILL_RE = re.compile(r"^/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\s+|$)")

View File

@ -18,6 +18,8 @@ from textual.screen import ModalScreen
from textual.widgets import Input, Label, OptionList, Static
from textual.widgets.option_list import Option
from deerflow.runtime.goal import parse_goal_command
from .input_history import InputHistory
from .render import render_header, render_status, render_transcript
from .runtime import stream_actions
@ -34,7 +36,7 @@ from .view_state import (
)
from .widgets.composer import ComposerInput
_HELP_TEXT = "Commands: /new /threads /model /skills /tools /mcp /memory /usage /config /quit\nKeys: Enter send · Ctrl+C interrupt or quit · Ctrl+L redraw · / commands · Esc close overlay"
_HELP_TEXT = "Commands: /new /threads /goal /model /skills /tools /mcp /memory /usage /config /quit\nKeys: Enter send · Ctrl+C interrupt or quit · Ctrl+L redraw · / commands · Esc close overlay"
class SelectScreen(ModalScreen):
@ -368,6 +370,8 @@ class DeerFlowTUI(App):
self._open_thread_switcher()
elif name == "resume":
self._resume_thread(args)
elif name == "goal":
self._handle_goal(args)
elif name == "skills":
self._show_skills()
elif name == "mcp":
@ -446,6 +450,44 @@ class DeerFlowTUI(App):
self._dispatch(SystemMessage(f"Resumed thread {thread_id[:8]}."))
self._refresh_header()
def _handle_goal(self, args: str) -> None:
command = parse_goal_command(args)
if command.kind == "status":
if not self._conv_thread_id:
self._dispatch(SystemMessage("No active goal."))
return
try:
goal = self.session.client.get_goal(self._conv_thread_id).get("goal")
except Exception: # noqa: BLE001
self._dispatch(SystemMessage("Could not read goal.", tone="error"))
return
if not goal:
self._dispatch(SystemMessage("No active goal."))
return
self._dispatch(SystemMessage(f"Goal: {goal.get('objective')}"))
return
if command.kind == "clear":
if self._conv_thread_id:
try:
self.session.client.clear_goal(self._conv_thread_id)
except Exception: # noqa: BLE001
self._dispatch(SystemMessage("Could not clear goal.", tone="error"))
return
self._dispatch(SystemMessage("Goal cleared."))
return
if self._conv_thread_id is None:
self._conv_thread_id = str(uuid.uuid4())
self._refresh_header()
try:
goal = self.session.client.set_goal(self._conv_thread_id, command.objective).get("goal")
except Exception: # noqa: BLE001
self._dispatch(SystemMessage("Could not set goal.", tone="error"))
return
self._dispatch(SystemMessage(f"Goal set: {goal.get('objective') if goal else command.objective}"))
def _show_skills(self) -> None:
names = ", ".join(self._skill_names) or "none"
self._dispatch(SystemMessage(f"Enabled skills ({self._skills}): {names}"))

View File

@ -39,6 +39,7 @@ BUILTIN_COMMANDS: tuple[Command, ...] = (
Command("threads", "Open the thread switcher"),
Command("switch", "Open the thread switcher"),
Command("resume", "Resume a thread by id or title"),
Command("goal", "Set, show or clear the active goal"),
Command("model", "Open the model picker"),
Command("skills", "Browse enabled and available skills"),
Command("tools", "Show built-in, MCP and sandbox tools"),

View File

@ -0,0 +1,49 @@
"""Utilities for normalizing LLM response text before structured parsing."""
from __future__ import annotations
import re
# Matches a complete <think>...</think> block (case-insensitive, spans newlines).
_THINK_BLOCK_RE = re.compile(r"<think\b[^>]*>.*?</think\s*>", re.IGNORECASE | re.DOTALL)
# Matches a dangling, unclosed <think> (model truncated at max_tokens mid-thought).
_OPEN_THINK_RE = re.compile(r"<think\b[^>]*>", re.IGNORECASE)
def strip_think_blocks(text: str) -> str:
"""Remove inline reasoning ``<think>`` blocks from a model response."""
text = _THINK_BLOCK_RE.sub("", text)
open_match = _OPEN_THINK_RE.search(text)
if open_match:
text = text[: open_match.start()]
return text.strip()
def strip_markdown_code_fence(text: str) -> str:
"""Remove a single wrapping markdown code fence when present."""
stripped = text.strip()
if not stripped.startswith("```"):
return stripped
lines = stripped.splitlines()
if len(lines) >= 3 and lines[0].startswith("```") and lines[-1].startswith("```"):
return "\n".join(lines[1:-1]).strip()
return stripped
def extract_response_text(content: object) -> str:
"""Extract textual content from common chat-model response content shapes."""
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict) and block.get("type") in {"text", "output_text"}:
text = block.get("text")
if isinstance(text, str):
parts.append(text)
return "\n".join(parts)
if content is None:
return ""
return str(content)

View File

@ -4972,7 +4972,7 @@ class TestChannelService:
service._start_channel = mock_start_channel
async def go():
await service.restart_channel("feishu")
await service.restart_channel("feishu", reload_config=False)
_run(go())
@ -6323,3 +6323,188 @@ class TestTelegramStreaming:
assert ch._last_bot_message["12345"] == 101
_run(go())
class TestHandleGoalCommand:
"""Covers the IM-channel ``/goal`` handler (get/set/clear via the Gateway)."""
@staticmethod
def _install_mock_httpx(monkeypatch, calls, *, goal_payload=None, fail_method=None):
class MockResponse:
def raise_for_status(self):
return None
def json(self):
return {"goal": goal_payload}
class MockAsyncClient:
def __init__(self, *args, **kwargs):
return None
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return None
async def _record(self, method, url, **kwargs):
calls.append({"method": method, "url": url, **kwargs})
if fail_method == method:
raise RuntimeError("gateway down")
return MockResponse()
async def get(self, url, **kwargs):
return await self._record("get", url, **kwargs)
async def put(self, url, **kwargs):
return await self._record("put", url, **kwargs)
async def delete(self, url, **kwargs):
return await self._record("delete", url, **kwargs)
monkeypatch.setattr("app.channels.manager.httpx.AsyncClient", MockAsyncClient)
@staticmethod
def _make_manager(monkeypatch, *, thread_id):
from app.channels.manager import ChannelManager
bus = MessageBus()
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
manager = ChannelManager(bus=bus, store=store, gateway_url="http://gateway:8001")
async def _lookup(msg):
return thread_id
monkeypatch.setattr(manager, "_lookup_thread_id", _lookup)
return manager
@staticmethod
def _msg(text):
return InboundMessage(
channel_name="slack",
chat_id="C1",
user_id="U1",
text=text,
msg_type=InboundMessageType.COMMAND,
)
def test_status_without_thread_reports_no_active_goal(self, monkeypatch):
calls = []
self._install_mock_httpx(monkeypatch, calls)
async def go():
manager = self._make_manager(monkeypatch, thread_id=None)
reply = await manager._handle_goal_command(self._msg("/goal"), "")
assert reply == "No active goal."
assert calls == [] # no thread -> no gateway round-trip
_run(go())
def test_status_with_active_goal_reports_objective(self, monkeypatch):
calls = []
self._install_mock_httpx(monkeypatch, calls, goal_payload={"objective": "ship it"})
async def go():
manager = self._make_manager(monkeypatch, thread_id="t-1")
reply = await manager._handle_goal_command(self._msg("/goal"), "")
assert reply == "Goal: ship it"
assert calls[0]["method"] == "get"
assert calls[0]["url"].endswith("/api/threads/t-1/goal")
_run(go())
def test_status_with_no_goal_reports_none(self, monkeypatch):
calls = []
self._install_mock_httpx(monkeypatch, calls, goal_payload=None)
async def go():
manager = self._make_manager(monkeypatch, thread_id="t-1")
reply = await manager._handle_goal_command(self._msg("/goal"), "")
assert reply == "No active goal."
_run(go())
def test_clear_with_thread_calls_delete(self, monkeypatch):
calls = []
self._install_mock_httpx(monkeypatch, calls)
async def go():
manager = self._make_manager(monkeypatch, thread_id="t-1")
reply = await manager._handle_goal_command(self._msg("/goal clear"), "clear")
assert reply == "Goal cleared."
assert calls[0]["method"] == "delete"
_run(go())
def test_clear_without_thread_is_noop(self, monkeypatch):
calls = []
self._install_mock_httpx(monkeypatch, calls)
async def go():
manager = self._make_manager(monkeypatch, thread_id=None)
reply = await manager._handle_goal_command(self._msg("/goal reset"), "reset")
assert reply == "Goal cleared."
assert calls == []
_run(go())
def test_set_with_existing_thread_puts_objective(self, monkeypatch):
calls = []
self._install_mock_httpx(monkeypatch, calls, goal_payload={"objective": "finish the work"})
async def go():
manager = self._make_manager(monkeypatch, thread_id="t-1")
chats = []
async def _handle_chat(msg, **kwargs):
chats.append((msg, kwargs))
monkeypatch.setattr(manager, "_handle_chat", _handle_chat)
reply = await manager._handle_goal_command(self._msg("/goal finish the work"), "finish the work")
assert reply is None
assert calls[0]["method"] == "put"
assert calls[0]["json"] == {"objective": "finish the work"}
assert chats[0][0].text == "finish the work"
assert chats[0][0].msg_type == InboundMessageType.CHAT
assert chats[0][1] == {"bound_identity_checked": True}
_run(go())
def test_set_without_thread_creates_one(self, monkeypatch):
calls = []
self._install_mock_httpx(monkeypatch, calls, goal_payload={"objective": "do X"})
async def go():
manager = self._make_manager(monkeypatch, thread_id=None)
chats = []
async def _create(client, msg):
return "new-thread"
async def _handle_chat(msg, **kwargs):
chats.append((msg, kwargs))
monkeypatch.setattr(manager, "_create_thread", _create)
monkeypatch.setattr(manager, "_get_client", lambda: object())
monkeypatch.setattr(manager, "_handle_chat", _handle_chat)
reply = await manager._handle_goal_command(self._msg("/goal do X"), "do X")
assert reply is None
assert calls[0]["method"] == "put"
assert calls[0]["url"].endswith("/api/threads/new-thread/goal")
assert chats[0][0].text == "do X"
assert chats[0][0].msg_type == InboundMessageType.CHAT
_run(go())
def test_set_failure_returns_error_message(self, monkeypatch):
calls = []
self._install_mock_httpx(monkeypatch, calls, fail_method="put")
async def go():
manager = self._make_manager(monkeypatch, thread_id="t-1")
reply = await manager._handle_goal_command(self._msg("/goal do X"), "do X")
assert reply == "Failed to set goal."
_run(go())

View File

@ -16,6 +16,7 @@ from app.gateway.routers.mcp import McpConfigResponse
from app.gateway.routers.memory import MemoryConfigResponse, MemoryStatusResponse
from app.gateway.routers.models import ModelResponse, ModelsListResponse
from app.gateway.routers.skills import SkillInstallResponse, SkillResponse, SkillsListResponse
from app.gateway.routers.threads import ThreadGoalResponse
from app.gateway.routers.uploads import UploadResponse
from deerflow.client import DeerFlowClient
from deerflow.config.paths import Paths
@ -1173,6 +1174,29 @@ class TestThreadQueries:
mock_checkpointer.list.assert_called_once_with({"configurable": {"thread_id": "t99"}})
# ---------------------------------------------------------------------------
# Goal management
# ---------------------------------------------------------------------------
class TestGoalManagement:
def test_goal_round_trip_uses_checkpoint(self, client):
from langgraph.checkpoint.memory import InMemorySaver
client._checkpointer = InMemorySaver()
set_result = client.set_goal("goal-thread", "finish all tests", max_continuations=3)
get_result = client.get_goal("goal-thread")
clear_result = client.clear_goal("goal-thread")
after_clear = client.get_goal("goal-thread")
assert set_result["goal"]["objective"] == "finish all tests"
assert set_result["goal"]["max_continuations"] == 3
assert get_result["goal"]["objective"] == "finish all tests"
assert clear_result == {"goal": None}
assert after_clear == {"goal": None}
# ---------------------------------------------------------------------------
# MCP config
# ---------------------------------------------------------------------------
@ -2463,6 +2487,18 @@ class TestGatewayConformance:
assert len(parsed.files) == 1
assert parsed.files[0].size == len("hello")
def test_goal_methods(self, client):
from langgraph.checkpoint.memory import InMemorySaver
client._checkpointer = InMemorySaver()
result = client.set_goal("t-goal", "ship it")
parsed = ThreadGoalResponse(**result)
assert parsed.goal is not None
assert parsed.goal["objective"] == "ship it"
assert ThreadGoalResponse(**client.clear_goal("t-goal")).goal is None
def test_get_memory_config(self, client):
mem_cfg = MagicMock()
mem_cfg.enabled = True

View File

@ -33,8 +33,9 @@ async def _run_lifespan_with_hanging_stop() -> float:
await asyncio.sleep(3600)
app = FastAPI()
startup_config = SimpleNamespace(log_level="INFO", memory=SimpleNamespace(token_counting="char"))
startup_config = MagicMock()
startup_config.log_level = "INFO"
startup_config.memory.token_counting = "char"
fake_service = MagicMock()
fake_service.get_status = MagicMock(return_value={})

View File

@ -0,0 +1,236 @@
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")

View File

@ -0,0 +1,689 @@
import asyncio
import copy
import pytest
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.checkpoint.base import empty_checkpoint, uuid6
from langgraph.checkpoint.memory import InMemorySaver
from deerflow.runtime.goal import GoalEvaluation, attach_goal_evaluation, build_goal_state, latest_visible_assistant_signature, read_thread_goal, write_thread_goal
from deerflow.runtime.runs import worker
from deerflow.runtime.runs.manager import RunRecord
from deerflow.runtime.runs.schemas import DisconnectMode, RunStatus
class _CollectingBridge:
def __init__(self) -> None:
self.events: list[tuple[str, object]] = []
async def publish(self, _run_id: str, event: str, payload: object) -> None:
self.events.append((event, payload))
class _ClearBeforeSecondGoalReadCheckpointer:
"""Wrap a saver and clear the goal just before the evaluator write rereads.
The first ``aget_tuple`` is the evaluator's current-goal read. The second is
``write_thread_goal`` preparing its read-modify-write. Clearing at that point
models a user ``/goal clear`` landing between those two operations.
"""
def __init__(self, inner: InMemorySaver, thread_id: str) -> None:
self.inner = inner
self.thread_id = thread_id
self.read_count = 0
self.cleared = False
def get_next_version(self, current, channel):
return self.inner.get_next_version(current, channel)
async def aget_tuple(self, config):
self.read_count += 1
if self.read_count == 2 and not self.cleared:
self.cleared = True
await write_thread_goal(self.inner, self.thread_id, None, as_node="test_clear")
return await self.inner.aget_tuple(config)
async def aput(self, *args, **kwargs):
return await self.inner.aput(*args, **kwargs)
async def _seed_goal_thread(
checkpointer: InMemorySaver,
*,
thread_id: str,
goal_text: str,
messages: list | None = None,
) -> None:
checkpoint = empty_checkpoint()
checkpoint["channel_values"] = {
"messages": messages
or [
HumanMessage(content="Please finish this task."),
AIMessage(content="I made a start, but I am not done."),
]
}
checkpoint["channel_versions"] = {"messages": 1}
checkpointer.put(
{"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}},
checkpoint,
{"step": 1},
{"messages": 1},
)
await write_thread_goal(checkpointer, thread_id, build_goal_state(goal_text, max_continuations=2))
async def _write_messages(checkpointer: InMemorySaver, *, thread_id: str, messages: list) -> None:
checkpoint_tuple = await checkpointer.aget_tuple({"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}})
assert checkpoint_tuple is not None
checkpoint = copy.deepcopy(getattr(checkpoint_tuple, "checkpoint", {}) or {})
metadata = copy.deepcopy(getattr(checkpoint_tuple, "metadata", {}) or {})
channel_values = dict(checkpoint.get("channel_values", {}) or {})
channel_values["messages"] = messages
checkpoint["channel_values"] = channel_values
channel_versions = dict(checkpoint.get("channel_versions", {}) or {})
current_version = channel_versions.get("messages")
channel_versions["messages"] = checkpointer.get_next_version(current_version, None)
checkpoint["channel_versions"] = channel_versions
checkpoint["id"] = str(uuid6())
metadata["step"] = metadata.get("step", 0) + 1
metadata["writes"] = {"test": {"messages": messages}}
await checkpointer.aput(
{"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}},
checkpoint,
metadata,
{"messages": channel_versions["messages"]},
)
@pytest.mark.asyncio
async def test_goal_worker_returns_hidden_continuation_when_goal_is_unmet(monkeypatch):
checkpointer = InMemorySaver()
thread_id = "goal-thread"
await _seed_goal_thread(checkpointer, thread_id=thread_id, goal_text="Finish all tests")
bridge = _CollectingBridge()
async def fake_evaluate_goal_completion(goal, messages, **_kwargs):
assert goal["objective"] == "Finish all tests"
assert [message.content for message in messages][-1] == "I made a start, but I am not done."
return GoalEvaluation(
satisfied=False,
blocker="goal_not_met_yet",
reason="Tests have not passed yet.",
evidence_summary="Implementation is incomplete.",
)
monkeypatch.setattr(worker, "evaluate_goal_completion", fake_evaluate_goal_completion)
continuation = await worker._prepare_goal_continuation_input(
bridge=bridge,
checkpointer=checkpointer,
thread_id=thread_id,
run_id="run-1",
model_name="test-model",
app_config=None,
)
assert continuation is not None
[message] = continuation["messages"]
assert message.additional_kwargs["hide_from_ui"] is True
assert "Finish all tests" in message.content
assert "Tests have not passed yet." in message.content
latest_goal = await read_thread_goal(checkpointer, thread_id)
assert latest_goal is not None
assert latest_goal["continuation_count"] == 1
assert latest_goal["last_evaluation"]["run_id"] == "run-1"
assert latest_goal["last_evaluation"]["blocker"] == "goal_not_met_yet"
assert "stand_down_reason" not in latest_goal["last_evaluation"]
assert bridge.events[0][0] == "values"
@pytest.mark.asyncio
async def test_goal_worker_clears_goal_when_evaluator_is_satisfied(monkeypatch):
checkpointer = InMemorySaver()
thread_id = "done-goal-thread"
await _seed_goal_thread(checkpointer, thread_id=thread_id, goal_text="Finish all tests")
bridge = _CollectingBridge()
async def fake_evaluate_goal_completion(_goal, _messages, **_kwargs):
return GoalEvaluation(
satisfied=True,
blocker="none",
reason="The visible conversation says the task is done.",
evidence_summary="Done.",
)
monkeypatch.setattr(worker, "evaluate_goal_completion", fake_evaluate_goal_completion)
continuation = await worker._prepare_goal_continuation_input(
bridge=bridge,
checkpointer=checkpointer,
thread_id=thread_id,
run_id="run-2",
model_name="test-model",
app_config=None,
)
assert continuation is None
assert await read_thread_goal(checkpointer, thread_id) is None
assert bridge.events[0][0] == "values"
@pytest.mark.asyncio
async def test_goal_worker_stands_down_for_non_continuable_blocker(monkeypatch):
checkpointer = InMemorySaver()
thread_id = "blocked-goal-thread"
await _seed_goal_thread(checkpointer, thread_id=thread_id, goal_text="Finish all tests")
bridge = _CollectingBridge()
async def fake_evaluate_goal_completion(_goal, _messages, **_kwargs):
return GoalEvaluation(
satisfied=False,
blocker="missing_evidence",
reason="The transcript does not prove any verification.",
evidence_summary="No test result is visible.",
)
monkeypatch.setattr(worker, "evaluate_goal_completion", fake_evaluate_goal_completion)
continuation = await worker._prepare_goal_continuation_input(
bridge=bridge,
checkpointer=checkpointer,
thread_id=thread_id,
run_id="run-3",
model_name="test-model",
app_config=None,
)
assert continuation is None
latest_goal = await read_thread_goal(checkpointer, thread_id)
assert latest_goal is not None
assert latest_goal["continuation_count"] == 0
assert latest_goal["last_evaluation"]["blocker"] == "missing_evidence"
assert latest_goal["last_evaluation"]["stand_down_reason"] == "blocked:missing_evidence"
@pytest.mark.asyncio
async def test_goal_worker_stands_down_when_no_progress_repeats(monkeypatch):
checkpointer = InMemorySaver()
thread_id = "no-progress-goal-thread"
messages = [HumanMessage(content="Please finish this task."), AIMessage(content="I made a start, but I am not done.")]
await _seed_goal_thread(checkpointer, thread_id=thread_id, goal_text="Finish all tests", messages=messages)
previous_goal = await read_thread_goal(checkpointer, thread_id)
assert previous_goal is not None
repeated_evaluation = GoalEvaluation(
satisfied=False,
blocker="goal_not_met_yet",
reason="The same work remains.",
evidence_summary="No new verification evidence.",
)
# Seed the prior evaluation with the SAME visible assistant evidence the worker
# will recompute, so the no-progress breaker recognises the stalled turn even
# though the evaluator may reword its free-text reason.
evidence_signature = latest_visible_assistant_signature(messages)
await write_thread_goal(
checkpointer,
thread_id,
attach_goal_evaluation(previous_goal, repeated_evaluation, run_id="previous-run", no_progress_count=1, evidence_signature=evidence_signature),
)
bridge = _CollectingBridge()
async def fake_evaluate_goal_completion(_goal, _messages, **_kwargs):
return repeated_evaluation
monkeypatch.setattr(worker, "evaluate_goal_completion", fake_evaluate_goal_completion)
continuation = await worker._prepare_goal_continuation_input(
bridge=bridge,
checkpointer=checkpointer,
thread_id=thread_id,
run_id="run-4",
model_name="test-model",
app_config=None,
)
assert continuation is None
latest_goal = await read_thread_goal(checkpointer, thread_id)
assert latest_goal is not None
assert latest_goal["no_progress_count"] == 2
assert latest_goal["last_evaluation"]["stand_down_reason"] == "no_progress_detected"
@pytest.mark.asyncio
async def test_goal_worker_does_not_resurrect_goal_cleared_during_evaluation(monkeypatch):
checkpointer = InMemorySaver()
thread_id = "clear-during-eval-thread"
await _seed_goal_thread(checkpointer, thread_id=thread_id, goal_text="Finish all tests")
bridge = _CollectingBridge()
async def fake_evaluate_goal_completion(_goal, _messages, **_kwargs):
await write_thread_goal(checkpointer, thread_id, None, as_node="test")
return GoalEvaluation(
satisfied=False,
blocker="goal_not_met_yet",
reason="More work remains.",
evidence_summary="Work remains.",
)
monkeypatch.setattr(worker, "evaluate_goal_completion", fake_evaluate_goal_completion)
continuation = await worker._prepare_goal_continuation_input(
bridge=bridge,
checkpointer=checkpointer,
thread_id=thread_id,
run_id="run-5",
model_name="test-model",
app_config=None,
)
assert continuation is None
assert await read_thread_goal(checkpointer, thread_id) is None
@pytest.mark.asyncio
async def test_goal_worker_does_not_resurrect_goal_cleared_during_persist():
checkpointer = InMemorySaver()
thread_id = "clear-during-persist-thread"
await _seed_goal_thread(checkpointer, thread_id=thread_id, goal_text="Finish all tests")
existing_goal = await read_thread_goal(checkpointer, thread_id)
assert existing_goal is not None
wrapped_checkpointer = _ClearBeforeSecondGoalReadCheckpointer(checkpointer, thread_id)
bridge = _CollectingBridge()
result = await worker._persist_goal_evaluation(
bridge=bridge,
checkpointer=wrapped_checkpointer,
thread_id=thread_id,
run_id="run-clear-during-persist",
goal=existing_goal,
evaluation=GoalEvaluation(
satisfied=False,
blocker="goal_not_met_yet",
reason="More work remains.",
evidence_summary="Work remains.",
),
no_progress_count=0,
)
assert result is None
assert wrapped_checkpointer.cleared is True
assert await read_thread_goal(checkpointer, thread_id) is None
@pytest.mark.asyncio
async def test_goal_worker_stops_when_abort_is_requested_during_evaluation(monkeypatch):
checkpointer = InMemorySaver()
thread_id = "abort-during-eval-thread"
await _seed_goal_thread(checkpointer, thread_id=thread_id, goal_text="Finish all tests")
bridge = _CollectingBridge()
abort_event = asyncio.Event()
async def fake_evaluate_goal_completion(_goal, _messages, **_kwargs):
abort_event.set()
return GoalEvaluation(
satisfied=False,
blocker="goal_not_met_yet",
reason="More work remains.",
evidence_summary="Work remains.",
)
monkeypatch.setattr(worker, "evaluate_goal_completion", fake_evaluate_goal_completion)
continuation = await worker._prepare_goal_continuation_input(
bridge=bridge,
checkpointer=checkpointer,
thread_id=thread_id,
run_id="run-abort",
model_name="test-model",
app_config=None,
abort_event=abort_event,
)
assert continuation is None
latest_goal = await read_thread_goal(checkpointer, thread_id)
assert latest_goal is not None
assert latest_goal["continuation_count"] == 0
assert "last_evaluation" not in latest_goal
@pytest.mark.asyncio
async def test_goal_worker_stands_down_when_thread_changes_after_evaluation(monkeypatch):
checkpointer = InMemorySaver()
thread_id = "user-wins-thread"
await _seed_goal_thread(checkpointer, thread_id=thread_id, goal_text="Finish all tests")
bridge = _CollectingBridge()
async def fake_evaluate_goal_completion(_goal, messages, **_kwargs):
await _write_messages(
checkpointer,
thread_id=thread_id,
messages=[*messages, HumanMessage(content="Actually, stop and wait.")],
)
return GoalEvaluation(
satisfied=False,
blocker="goal_not_met_yet",
reason="More work remains.",
evidence_summary="Work remains.",
)
monkeypatch.setattr(worker, "evaluate_goal_completion", fake_evaluate_goal_completion)
continuation = await worker._prepare_goal_continuation_input(
bridge=bridge,
checkpointer=checkpointer,
thread_id=thread_id,
run_id="run-6",
model_name="test-model",
app_config=None,
)
assert continuation is None
latest_goal = await read_thread_goal(checkpointer, thread_id)
assert latest_goal is not None
assert latest_goal["continuation_count"] == 0
assert latest_goal["last_evaluation"]["stand_down_reason"] == "thread_changed_after_evaluation"
@pytest.mark.asyncio
async def test_goal_worker_stands_down_without_durable_assistant_receipt():
checkpointer = InMemorySaver()
thread_id = "no-receipt-thread"
await _seed_goal_thread(
checkpointer,
thread_id=thread_id,
goal_text="Finish all tests",
messages=[HumanMessage(content="Please finish this task.")],
)
bridge = _CollectingBridge()
continuation = await worker._prepare_goal_continuation_input(
bridge=bridge,
checkpointer=checkpointer,
thread_id=thread_id,
run_id="run-7",
model_name="test-model",
app_config=None,
)
assert continuation is None
latest_goal = await read_thread_goal(checkpointer, thread_id)
assert latest_goal is not None
assert latest_goal["last_evaluation"]["blocker"] == "run_failed"
assert latest_goal["last_evaluation"]["stand_down_reason"] == "no_durable_end_of_turn"
def test_stand_down_reason_uses_documented_default_caps_when_missing():
"""_stand_down_reason must fall back to the same default caps as
should_continue_goal (8 / 2). A bare goal dict missing the cap fields must
not be reported as 'max reached' / 'no progress' when it has not actually
exhausted the documented defaults.
"""
bare_goal = {"objective": "x", "status": "active", "continuation_count": 0}
unmet = GoalEvaluation(satisfied=False, blocker="goal_not_met_yet", reason="", evidence_summary="")
assert worker._stand_down_reason(bare_goal, unmet, no_progress_count=0) is None
# And the two gate functions agree on the same bare goal.
from deerflow.runtime.goal import should_continue_goal
assert should_continue_goal(bare_goal, unmet, no_progress_count=0) is True
@pytest.mark.asyncio
async def test_run_agent_does_not_stream_continuation_after_abort(monkeypatch):
class FakeAgent:
def __init__(self) -> None:
self.inputs = []
self.metadata = {}
self.checkpointer = None
self.store = None
self.interrupt_before_nodes = []
self.interrupt_after_nodes = []
def astream(self, input_payload, **_kwargs):
self.inputs.append(input_payload)
async def _gen():
yield {"messages": []}
return _gen()
class FakeRunManager:
async def set_status(self, _run_id, status, **_kwargs):
record.status = status
async def update_model_name(self, *_args, **_kwargs):
return None
async def update_run_completion(self, *_args, **_kwargs):
return None
async def wait_for_prior_finalizing(self, *_args, **_kwargs):
return None
async def set_finalizing(self, _run_id, finalizing):
record.finalizing = finalizing
class FakeBridge:
async def publish(self, *_args, **_kwargs):
return None
async def publish_end(self, *_args, **_kwargs):
return None
async def cleanup(self, *_args, **_kwargs):
return None
async def fake_prepare(**kwargs):
kwargs["abort_event"].set()
return {"messages": [HumanMessage(content="continue", additional_kwargs={"hide_from_ui": True})]}
monkeypatch.setattr(worker, "_prepare_goal_continuation_input", fake_prepare)
fake_agent = FakeAgent()
record = RunRecord(
run_id="run-abort-loop",
thread_id="thread-abort-loop",
assistant_id="lead-agent",
status=RunStatus.pending,
on_disconnect=DisconnectMode.cancel,
model_name="test-model",
)
record.abort_event = asyncio.Event()
await worker.run_agent(
FakeBridge(),
FakeRunManager(),
record,
ctx=worker.RunContext(checkpointer=None),
agent_factory=lambda config: fake_agent,
graph_input={"messages": [HumanMessage(content="start")]},
config={"configurable": {"thread_id": "thread-abort-loop"}},
)
assert len(fake_agent.inputs) == 1
assert fake_agent.inputs[0] == {"messages": [HumanMessage(content="start")]}
assert record.status == RunStatus.interrupted
@pytest.mark.asyncio
async def test_run_agent_reuses_goal_evaluator_model_for_goal_loop(monkeypatch):
class FakeAgent:
def __init__(self) -> None:
self.inputs = []
self.metadata = {}
self.checkpointer = None
self.store = None
self.interrupt_before_nodes = []
self.interrupt_after_nodes = []
def astream(self, input_payload, **_kwargs):
self.inputs.append(input_payload)
async def _gen():
yield {"messages": []}
return _gen()
class FakeRunManager:
async def set_status(self, _run_id, status, **_kwargs):
record.status = status
async def update_model_name(self, *_args, **_kwargs):
return None
async def update_run_completion(self, *_args, **_kwargs):
return None
async def wait_for_prior_finalizing(self, *_args, **_kwargs):
return None
async def set_finalizing(self, _run_id, finalizing):
record.finalizing = finalizing
class FakeBridge:
async def publish(self, *_args, **_kwargs):
return None
async def publish_end(self, *_args, **_kwargs):
return None
async def cleanup(self, *_args, **_kwargs):
return None
evaluator_model = object()
create_calls = []
def fake_create_goal_evaluator_model(**kwargs):
create_calls.append(kwargs)
return evaluator_model
prepare_models = []
async def fake_prepare(**kwargs):
prepare_models.append(kwargs["evaluator_model_factory"]())
if len(prepare_models) == 1:
return {"messages": [HumanMessage(content="continue", additional_kwargs={"hide_from_ui": True})]}
return None
monkeypatch.setattr(worker, "create_goal_evaluator_model", fake_create_goal_evaluator_model)
monkeypatch.setattr(worker, "_prepare_goal_continuation_input", fake_prepare)
fake_agent = FakeAgent()
record = RunRecord(
run_id="run-model-cache",
thread_id="thread-model-cache",
assistant_id="lead-agent",
status=RunStatus.pending,
on_disconnect=DisconnectMode.cancel,
model_name="test-model",
)
record.abort_event = asyncio.Event()
await worker.run_agent(
FakeBridge(),
FakeRunManager(),
record,
ctx=worker.RunContext(checkpointer=None, app_config=object()),
agent_factory=lambda config: fake_agent,
graph_input={"messages": [HumanMessage(content="start")]},
config={"configurable": {"thread_id": "thread-model-cache"}},
)
assert len(fake_agent.inputs) == 2
assert prepare_models == [evaluator_model, evaluator_model]
assert len(create_calls) == 1
assert create_calls[0]["model_name"] == "test-model"
assert record.status == RunStatus.success
@pytest.mark.asyncio
async def test_run_agent_strips_branch_checkpoint_for_goal_continuation(monkeypatch):
class FakeAgent:
def __init__(self) -> None:
self.calls = []
self.metadata = {}
self.checkpointer = None
self.store = None
self.interrupt_before_nodes = []
self.interrupt_after_nodes = []
def astream(self, input_payload, **kwargs):
configurable = dict(kwargs["config"].get("configurable", {}))
self.calls.append((input_payload, configurable))
async def _gen():
yield {"messages": []}
return _gen()
class FakeRunManager:
async def set_status(self, _run_id, status, **_kwargs):
record.status = status
async def update_model_name(self, *_args, **_kwargs):
return None
async def update_run_completion(self, *_args, **_kwargs):
return None
async def wait_for_prior_finalizing(self, *_args, **_kwargs):
return None
async def set_finalizing(self, _run_id, finalizing):
record.finalizing = finalizing
class FakeBridge:
async def publish(self, *_args, **_kwargs):
return None
async def publish_end(self, *_args, **_kwargs):
return None
async def cleanup(self, *_args, **_kwargs):
return None
async def fake_prepare(**_kwargs):
if len(fake_agent.calls) == 1:
return {"messages": [HumanMessage(content="continue", additional_kwargs={"hide_from_ui": True})]}
return None
monkeypatch.setattr(worker, "_prepare_goal_continuation_input", fake_prepare)
fake_agent = FakeAgent()
record = RunRecord(
run_id="run-branch-continuation",
thread_id="thread-branch-continuation",
assistant_id="lead-agent",
status=RunStatus.pending,
on_disconnect=DisconnectMode.cancel,
model_name="test-model",
)
record.abort_event = asyncio.Event()
await worker.run_agent(
FakeBridge(),
FakeRunManager(),
record,
ctx=worker.RunContext(checkpointer=None),
agent_factory=lambda config: fake_agent,
graph_input={"messages": [HumanMessage(content="start")]},
config={
"configurable": {
"thread_id": "thread-branch-continuation",
"checkpoint_ns": "branch",
"checkpoint_id": "old-checkpoint",
"checkpoint_map": {"": "old-checkpoint"},
}
},
)
assert len(fake_agent.calls) == 2
first_config = fake_agent.calls[0][1]
second_config = fake_agent.calls[1][1]
assert first_config["checkpoint_ns"] == "branch"
assert first_config["checkpoint_id"] == "old-checkpoint"
assert first_config["checkpoint_map"] == {"": "old-checkpoint"}
assert second_config["checkpoint_ns"] == ""
assert "checkpoint_id" not in second_config
assert "checkpoint_map" not in second_config
assert second_config["thread_id"] == "thread-branch-continuation"

View File

@ -73,7 +73,7 @@ def test_parse_slash_skill_reference_rejects_invalid_names():
def test_resolve_slash_skill_ignores_reserved_control_commands(tmp_path):
for command in ["bootstrap", "help", "memory", "models", "new", "status"]:
for command in ["bootstrap", "goal", "help", "memory", "models", "new", "status"]:
skill = _make_skill(tmp_path, command)
assert resolve_slash_skill(f"/{command} create an agent", [skill]) is None

View File

@ -17,6 +17,7 @@ from deerflow.agents.thread_state import (
ThreadState,
merge_artifacts,
merge_delegations,
merge_goal,
merge_sandbox,
merge_skill_context,
merge_todos,
@ -87,6 +88,23 @@ class TestMergeTodos:
assert merge_todos(existing, []) == []
class TestMergeGoal:
"""Reducer for ThreadState.goal - preserves active goal on untouched nodes."""
def test_none_new_preserves_existing(self):
existing = {"objective": "ship the feature", "status": "active"}
assert merge_goal(existing, None) == existing
def test_none_existing_accepts_new(self):
new = {"objective": "ship the feature", "status": "active"}
assert merge_goal(None, new) == new
def test_new_value_overrides_existing(self):
existing = {"objective": "old", "status": "active"}
new = {"objective": "new", "status": "active"}
assert merge_goal(existing, new) == new
class TestMergeArtifacts:
"""Sanity check for the existing artifacts reducer."""
@ -299,6 +317,13 @@ class TestThreadStateAnnotations:
assert hasattr(todos_hint, "__metadata__"), "ThreadState.todos must be Annotated with a reducer"
assert merge_todos in todos_hint.__metadata__, "ThreadState.todos must be wired to merge_todos reducer (see #3123)"
def test_goal_field_is_wired_to_merge_goal(self):
"""ThreadState.goal must preserve active goals across partial writes."""
hints = get_type_hints(ThreadState, include_extras=True)
goal_hint = hints["goal"]
assert hasattr(goal_hint, "__metadata__"), "ThreadState.goal must be Annotated with a reducer"
assert merge_goal in goal_hint.__metadata__, "ThreadState.goal must be wired to merge_goal reducer"
def test_artifacts_field_is_wired_to_merge_artifacts(self):
"""Sanity check that existing reducer wiring is preserved."""
hints = get_type_hints(ThreadState, include_extras=True)

View File

@ -219,6 +219,50 @@ def test_create_thread_returns_iso_timestamps() -> None:
assert body["created_at"] == body["updated_at"]
def test_put_goal_creates_missing_thread_checkpoint_and_returns_goal() -> None:
app, _store, _checkpointer = _build_thread_app()
with TestClient(app) as client:
response = client.put(
"/api/threads/goal-thread/goal",
json={"objective": "Finish the feature and make all tests pass"},
)
state_response = client.get("/api/threads/goal-thread/state")
assert response.status_code == 200, response.text
body = response.json()
assert body["goal"]["objective"] == "Finish the feature and make all tests pass"
assert body["goal"]["status"] == "active"
assert body["goal"]["continuation_count"] == 0
assert body["goal"]["max_continuations"] == 8
assert state_response.status_code == 200, state_response.text
assert state_response.json()["values"]["goal"]["objective"] == "Finish the feature and make all tests pass"
def test_goal_status_and_clear_round_trip() -> None:
app, _store, _checkpointer = _build_thread_app()
with TestClient(app) as client:
set_response = client.put(
"/api/threads/goal-thread/goal",
json={"objective": "Ship it", "max_continuations": 3},
)
get_response = client.get("/api/threads/goal-thread/goal")
clear_response = client.delete("/api/threads/goal-thread/goal")
after_clear_response = client.get("/api/threads/goal-thread/goal")
state_response = client.get("/api/threads/goal-thread/state")
assert set_response.status_code == 200, set_response.text
assert get_response.status_code == 200, get_response.text
assert get_response.json()["goal"]["objective"] == "Ship it"
assert get_response.json()["goal"]["max_continuations"] == 3
assert clear_response.status_code == 200, clear_response.text
assert clear_response.json()["goal"] is None
assert after_clear_response.status_code == 200, after_clear_response.text
assert after_clear_response.json()["goal"] is None
assert "goal" not in state_response.json()["values"]
def test_internal_owner_header_assigns_thread_to_owner() -> None:
import asyncio
@ -250,6 +294,35 @@ def test_internal_owner_header_assigns_thread_to_owner() -> None:
assert internal_row is None
def test_goal_thread_creation_uses_internal_owner_header() -> None:
import asyncio
from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME, INTERNAL_SYSTEM_ROLE
store = InMemoryStore()
checkpointer = InMemorySaver()
thread_store = MemoryThreadMetaStore(store)
request = SimpleNamespace(
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: "owner-1"},
state=SimpleNamespace(user=SimpleNamespace(id="default", system_role=INTERNAL_SYSTEM_ROLE)),
app=SimpleNamespace(state=SimpleNamespace(checkpointer=checkpointer, thread_store=thread_store)),
)
async def _scenario():
await threads._ensure_thread_for_goal("channel-goal-thread", request)
owner_row = await thread_store.get("channel-goal-thread", user_id="owner-1")
internal_row = await thread_store.get("channel-goal-thread", user_id="default")
owner_threads = await thread_store.search(user_id="owner-1")
return owner_row, internal_row, owner_threads
owner_row, internal_row, owner_threads = asyncio.run(_scenario())
assert owner_row is not None
assert owner_row["user_id"] == "owner-1"
assert internal_row is None
assert [thread["thread_id"] for thread in owner_threads] == ["channel-goal-thread"]
def test_get_thread_returns_iso_for_legacy_unix_record() -> None:
"""A thread record written by older versions stores ``time.time()``
floats. ``get_thread`` must transparently surface them as ISO so the

View File

@ -146,3 +146,112 @@ async def test_unknown_command_shows_error_system_row():
pilot,
)
assert any(r.kind == "system" and r.tone == "error" for r in app.state.rows)
# --------------------------------------------------------------------------- #
# /goal handler
# --------------------------------------------------------------------------- #
class _GoalClient(_FakeClient):
"""Records goal API calls and keeps an in-memory active goal."""
def __init__(self):
self.calls: list[tuple] = []
self.goal: dict | None = None
def get_goal(self, thread_id):
self.calls.append(("get", thread_id))
return {"goal": self.goal}
def set_goal(self, thread_id, objective):
self.calls.append(("set", thread_id, objective))
self.goal = {"objective": objective, "status": "active"}
return {"goal": self.goal}
def clear_goal(self, thread_id):
self.calls.append(("clear", thread_id))
self.goal = None
return {"goal": None}
class _GoalSession(_FakeSession):
def __init__(self):
self.client = _GoalClient()
def _system_rows(app):
return [r for r in app.state.rows if r.kind == "system"]
@pytest.mark.asyncio
async def test_goal_set_mints_thread_and_reports_objective():
session = _GoalSession()
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
assert app._conv_thread_id is None
app._handle_goal("finish the work")
await pilot.pause()
assert app._conv_thread_id is not None
assert ("set", app._conv_thread_id, "finish the work") in session.client.calls
assert any("Goal set: finish the work" in r.text for r in _system_rows(app))
@pytest.mark.asyncio
async def test_goal_status_without_thread_reports_no_active_goal():
session = _GoalSession()
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
app._handle_goal("")
await pilot.pause()
# No thread yet -> no gateway round-trip.
assert session.client.calls == []
assert any(r.text == "No active goal." for r in _system_rows(app))
@pytest.mark.asyncio
async def test_goal_status_reports_active_objective():
session = _GoalSession()
session.client.goal = {"objective": "ship it", "status": "active"}
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
app._conv_thread_id = "t-1"
app._handle_goal("")
await pilot.pause()
assert ("get", "t-1") in session.client.calls
assert any("Goal: ship it" in r.text for r in _system_rows(app))
@pytest.mark.asyncio
async def test_goal_clear_calls_gateway_and_confirms():
session = _GoalSession()
session.client.goal = {"objective": "ship it", "status": "active"}
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
app._conv_thread_id = "t-1"
app._handle_goal("clear")
await pilot.pause()
assert ("clear", "t-1") in session.client.calls
assert any(r.text == "Goal cleared." for r in _system_rows(app))
@pytest.mark.asyncio
async def test_goal_set_failure_shows_error_tone():
class _Boom(_GoalClient):
def set_goal(self, thread_id, objective):
raise RuntimeError("gateway down")
session = _GoalSession()
session.client = _Boom()
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
app._conv_thread_id = "t-1"
app._handle_goal("do it")
await pilot.pause()
errors = [r for r in _system_rows(app) if r.tone == "error"]
assert any("Could not set goal." in r.text for r in errors)

View File

@ -92,3 +92,18 @@ def test_resolve_unknown_command():
def test_resolve_bare_slash_is_unknown_empty():
res = resolve("/")
assert res.kind == "unknown"
def test_goal_is_builtin_command():
resolved = resolve("/goal finish the implementation")
assert resolved.kind == "builtin"
assert resolved.name == "goal"
assert resolved.args == "finish the implementation"
def test_goal_builtin_takes_precedence_over_skill():
registry = build_registry([{"name": "goal", "description": "skill", "enabled": True}])
assert [command.name for command in registry].count("goal") == 1
assert resolve("/goal finish", skills=["goal"]).kind == "builtin"

View File

@ -42,7 +42,7 @@ Frontend (Next.js) ──▶ LangGraph SDK ──▶ LangGraph Backend (lead_age
└── Tools & Skills
```
The frontend is a stateful chat application. Users create **threads** (conversations), send messages, and receive streamed AI responses. The backend orchestrates agents that can produce **artifacts** (files/code) and **todos**.
The frontend is a stateful chat application. Users create **threads** (conversations), send messages, set thread-scoped `/goal` completion conditions, and receive streamed AI responses. The backend orchestrates agents that can produce **artifacts** (files/code), **todos**, and goal state updates.
### Source Layout (`src/`)
@ -64,11 +64,13 @@ The frontend is a stateful chat application. Users create **threads** (conversat
### Data Flow
1. User input → thread hooks (`core/threads/hooks.ts`) → LangGraph SDK streaming
2. Stream events update thread state (messages, artifacts, todos)
2. Stream events update thread state (messages, artifacts, todos, goal)
3. Stop actions call the LangGraph SDK stream stop path; `core/threads/hooks.ts` invalidates current-thread, token-usage, and sidebar/search caches immediately and schedules one follow-up refetch because SDK stop may finish via abort + fire-and-forget cancel before backend title finalization commits
4. TanStack Query manages server state; localStorage stores user settings
5. Components subscribe to thread state and render updates
`/goal` is a built-in composer command, not a skill activation. `src/components/workspace/input-box.tsx` intercepts `/goal`, `/goal clear`, and `/goal <condition>` before normal chat submission, calling Gateway `GET/PUT/DELETE /api/threads/{thread_id}/goal`. Setting `/goal <condition>` also submits the condition text as the next user task so the agent starts running immediately; status and clear do not start a run. Goal requests are tied to the current `threadId` with an `AbortController`, so switching threads or unmounting the composer aborts in-flight goal requests and stale responses cannot update the new thread's goal state. The chat pages render `GoalStatus` above the composer from `AgentThreadState.goal`, with local optimistic state until the next stream `values` update arrives.
### Key Patterns
- **Server Components by default**, `"use client"` only for interactive components
@ -80,6 +82,7 @@ The frontend is a stateful chat application. Users create **threads** (conversat
### Interaction Ownership
- `src/app/workspace/chats/[thread_id]/page.tsx` owns composer busy-state wiring.
- `src/app/workspace/chats/[thread_id]/page.tsx` and `src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx` own active-goal display state for their composer overlays.
- `src/core/threads/hooks.ts` owns pre-submit upload state and thread submission.
## Code Style

View File

@ -11,6 +11,7 @@ import { AgentWelcome } from "@/components/workspace/agent-welcome";
import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import { GoalStatus } from "@/components/workspace/goal-status";
import { InputBox } from "@/components/workspace/input-box";
import {
MessageList,
@ -21,6 +22,7 @@ import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list";
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
import { Tooltip } from "@/components/workspace/tooltip";
import { useActiveGoal } from "@/components/workspace/use-active-goal";
import { useAgent } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
@ -162,6 +164,10 @@ export default function AgentChatPage() {
? localSettings.tokenUsage.inlineMode
: "off";
const hasTodos = (thread.values.todos?.length ?? 0) > 0;
const { activeGoal, hasGoal, setLocalGoal } = useActiveGoal(
threadId,
thread.values.goal,
);
return (
<ThreadContext.Provider value={{ thread }}>
@ -247,7 +253,7 @@ export default function AgentChatPage() {
: "max-w-(--container-width-md)",
)}
>
{hasTodos && (
{(hasGoal || hasTodos) && (
<div
className={cn(
"right-0 left-0 z-0",
@ -256,15 +262,18 @@ export default function AgentChatPage() {
>
<div
className={cn(
"right-0 bottom-0 left-0",
"right-0 bottom-0 left-0 flex flex-col",
isWelcomeMode ? "absolute" : "relative",
)}
>
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
hidden={false}
/>
{activeGoal && <GoalStatus goal={activeGoal} />}
{hasTodos && (
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
hidden={false}
/>
)}
</div>
</div>
)}
@ -286,7 +295,9 @@ export default function AgentChatPage() {
}
context={settings.context}
extraHeader={
isWelcomeMode && (
isWelcomeMode &&
!hasGoal &&
!hasTodos && (
<AgentWelcome agent={agent} agentName={agent_name} />
)
}
@ -295,6 +306,7 @@ export default function AgentChatPage() {
isUploading
}
onContextChange={(context) => setSettings("context", context)}
onGoalChange={setLocalGoal}
onSubmit={handleSubmit}
onStop={handleStop}
/>

View File

@ -12,6 +12,7 @@ import {
useThreadChat,
} from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import { GoalStatus } from "@/components/workspace/goal-status";
import { InputBox } from "@/components/workspace/input-box";
import {
MessageList,
@ -21,6 +22,7 @@ import { ThreadContext } from "@/components/workspace/messages/context";
import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list";
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
import { useActiveGoal } from "@/components/workspace/use-active-goal";
import { Welcome } from "@/components/workspace/welcome";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
@ -170,6 +172,10 @@ export default function ChatPage() {
? localSettings.tokenUsage.inlineMode
: "off";
const hasTodos = (thread.values.todos?.length ?? 0) > 0;
const { activeGoal, hasGoal, setLocalGoal } = useActiveGoal(
threadId,
thread.values.goal,
);
return (
<ThreadContext.Provider value={{ thread, isMock }}>
@ -240,7 +246,7 @@ export default function ChatPage() {
: "max-w-(--container-width-md)",
)}
>
{hasTodos && (
{(hasGoal || hasTodos) && (
<div
className={cn(
"right-0 left-0 z-0",
@ -249,15 +255,18 @@ export default function ChatPage() {
>
<div
className={cn(
"right-0 bottom-0 left-0",
"right-0 bottom-0 left-0 flex flex-col",
isWelcomeMode ? "absolute" : "relative",
)}
>
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
hidden={false}
/>
{activeGoal && <GoalStatus goal={activeGoal} />}
{hasTodos && (
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
hidden={false}
/>
)}
</div>
</div>
)}
@ -279,7 +288,9 @@ export default function ChatPage() {
}
context={settings.context}
extraHeader={
isWelcomeMode && <Welcome mode={settings.context.mode} />
isWelcomeMode &&
!hasGoal &&
!hasTodos && <Welcome mode={settings.context.mode} />
}
disabled={
isMock ||
@ -289,6 +300,7 @@ export default function ChatPage() {
onContextChange={(context) =>
setSettings("context", context)
}
onGoalChange={setLocalGoal}
onSubmit={handleSubmit}
onStop={handleStop}
/>

View File

@ -0,0 +1,47 @@
import type { GoalState } from "@/core/threads";
export type GoalContinuationDisplay = {
count: number;
max: number;
};
/**
* Decide the continuation counter to render for an active goal.
*
* Returns `null` until the agent has actually auto-continued at least once
* (`continuation_count > 0`). Before that, the raw "0/8" reads as a mysterious
* score, so the counter is hidden; once continuation starts it surfaces as
* "{count}/{max}" with an explanatory tooltip.
*/
export function getGoalContinuationDisplay(
goal: Pick<GoalState, "continuation_count" | "max_continuations">,
): GoalContinuationDisplay | null {
const count = goal.continuation_count ?? 0;
const max = goal.max_continuations ?? 0;
if (!Number.isFinite(count) || count <= 0) {
return null;
}
return { count, max };
}
/**
* Stable signature of the *server* goal, used to decide when an optimistic
* client override should yield back to server state.
*
* It changes whenever a new goal is set (`created_at`), the agent auto-continues
* (`continuation_count`/`updated_at`), or the backend clears/satisfies the goal
* (`null`). `useActiveGoal` resets its optimistic copy when this key changes, so
* the streamed continuation counter is never permanently shadowed.
*/
export function goalReconciliationKey(goal: GoalState | null): string {
if (!goal) {
return "none";
}
return [
goal.objective,
goal.status,
goal.created_at ?? "",
goal.updated_at ?? "",
goal.continuation_count ?? 0,
].join("|");
}

View File

@ -0,0 +1,51 @@
"use client";
import { RefreshCwIcon, TargetIcon } from "lucide-react";
import { useI18n } from "@/core/i18n/hooks";
import type { GoalState } from "@/core/threads";
import { cn } from "@/lib/utils";
import { getGoalContinuationDisplay } from "./goal-status-helpers";
import { Tooltip } from "./tooltip";
export function GoalStatus({
className,
goal,
}: {
className?: string;
goal: GoalState;
}) {
const { t } = useI18n();
const continuation = getGoalContinuationDisplay(goal);
return (
<div
className={cn(
"bg-background/90 border-border flex min-h-10 w-full items-center gap-3 rounded-t-xl border border-b-0 px-4 py-2 text-sm shadow-sm backdrop-blur-sm",
className,
)}
>
<TargetIcon className="text-primary size-4 shrink-0" />
<div className="min-w-0 flex-1 truncate">
<span className="text-muted-foreground mr-2">
{t.inputBox.goalLabel}
</span>
<span className="font-medium">{goal.objective}</span>
</div>
{continuation && (
<Tooltip
content={t.inputBox.goalContinuationTooltip
.replace("{count}", String(continuation.count))
.replace("{max}", String(continuation.max))}
>
<span className="text-muted-foreground flex shrink-0 items-center gap-1 text-xs tabular-nums">
<RefreshCwIcon className="size-3" />
{t.inputBox.goalContinuing
.replace("{count}", String(continuation.count))
.replace("{max}", String(continuation.max))}
</span>
</Tooltip>
)}
</div>
);
}

View File

@ -0,0 +1,232 @@
import type { Skill } from "@/core/skills";
export const MAX_SKILL_SUGGESTIONS = 6;
export const SUGGESTION_TEMPLATE_PLACEHOLDER_PATTERN =
/\[(?:主题|来源|topic|source)\]/i;
export type SlashSuggestion = {
name: string;
description: string;
kind: "builtin" | "skill";
};
export type GoalCommand =
| { kind: "status" }
| { kind: "clear" }
| { kind: "set"; objective: string };
export type InputSubmitAction =
| { kind: "goal"; command: GoalCommand }
| { kind: "stop" }
| { kind: "empty" }
| { kind: "message" };
export type GoalRequestState = {
controller: AbortController | null;
sequence: number;
threadId: string | null;
};
export type ActiveGoalRequest = {
controller: AbortController;
sequence: number;
threadId: string;
};
export function createGoalRequestState(): GoalRequestState {
return {
controller: null,
sequence: 0,
threadId: null,
};
}
export function beginGoalRequest(
state: GoalRequestState,
threadId: string,
): ActiveGoalRequest {
state.controller?.abort();
const controller = new AbortController();
const request = {
controller,
sequence: state.sequence + 1,
threadId,
};
state.controller = controller;
state.sequence = request.sequence;
state.threadId = threadId;
return request;
}
export function abortGoalRequest(state: GoalRequestState): void {
state.controller?.abort();
state.controller = null;
state.sequence += 1;
state.threadId = null;
}
export function finishGoalRequest(
state: GoalRequestState,
request: ActiveGoalRequest,
): void {
if (
state.controller === request.controller &&
state.sequence === request.sequence
) {
state.controller = null;
}
}
export function isCurrentGoalRequest(
state: GoalRequestState,
request: ActiveGoalRequest,
threadId: string,
): boolean {
return (
state.controller === request.controller &&
state.sequence === request.sequence &&
state.threadId === threadId &&
!request.controller.signal.aborted
);
}
export function isAbortError(error: unknown): boolean {
return (
(error instanceof DOMException && error.name === "AbortError") ||
(typeof error === "object" &&
error !== null &&
Reflect.get(error, "name") === "AbortError")
);
}
export function findSuggestionTemplatePlaceholder(text: string) {
const match = SUGGESTION_TEMPLATE_PLACEHOLDER_PATTERN.exec(text);
if (!match) {
return null;
}
return {
start: match.index,
end: match.index + match[0].length,
};
}
export function getLeadingSlashSkillQuery(value: string): string | null {
if (!value.startsWith("/")) {
return null;
}
const query = value.slice(1);
if (query.includes("/") || /\s/.test(query)) {
return null;
}
return query;
}
export function getMatchingSkillSuggestions(
skills: Skill[],
query: string,
builtinCommands: SlashSuggestion[],
): SlashSuggestion[] {
const normalizedQuery = query.toLowerCase();
const builtinCommandNames = new Set(
builtinCommands.map(({ name }) => name.toLowerCase()),
);
const builtinMatches = builtinCommands.filter(({ name, description }) => {
if (!normalizedQuery) {
return true;
}
return (
name.toLowerCase().includes(normalizedQuery) ||
description.toLowerCase().includes(normalizedQuery)
);
});
const skillMatches = skills
.map((skill, index) => ({
skill,
index,
name: skill.name.toLowerCase(),
}))
.filter(({ skill, name }) => {
if (!skill.enabled) {
return false;
}
if (builtinCommandNames.has(name)) {
return false;
}
return !normalizedQuery || name.includes(normalizedQuery);
})
.sort((a, b) => {
const aStartsWith = a.name.startsWith(normalizedQuery);
const bStartsWith = b.name.startsWith(normalizedQuery);
if (aStartsWith !== bStartsWith) {
return aStartsWith ? -1 : 1;
}
return a.index - b.index;
})
.slice(0, MAX_SKILL_SUGGESTIONS)
.map(({ skill }) => ({
name: skill.name,
description: skill.description,
kind: "skill" as const,
}));
return [...skillMatches, ...builtinMatches].slice(0, MAX_SKILL_SUGGESTIONS);
}
export function parseGoalCommand(value: string): GoalCommand | null {
const trimmed = value.trim();
const match = /^\/goal(?:\s+|$)/i.exec(trimmed);
if (!match) {
return null;
}
const args = trimmed.slice(match[0].length).trim();
if (!args) {
return { kind: "status" };
}
if (["clear", "reset", "off"].includes(args.toLowerCase())) {
return { kind: "clear" };
}
return { kind: "set", objective: args };
}
export function getInputSubmitAction({
text,
fileCount,
status,
}: {
text: string;
fileCount: number;
status: string;
}): InputSubmitAction {
const goalCommand = parseGoalCommand(text);
if (goalCommand && fileCount === 0) {
return { kind: "goal", command: goalCommand };
}
if (status === "streaming") {
return { kind: "stop" };
}
if (!text.trim() && fileCount === 0) {
return { kind: "empty" };
}
return { kind: "message" };
}
export async function readGoalResponseError(
response: Response,
): Promise<string> {
try {
const body = (await response.json()) as { detail?: unknown };
if (typeof body.detail === "string") {
return body.detail;
}
} catch {
// Fall through to generic message.
}
return `HTTP ${response.status}`;
}

View File

@ -9,6 +9,7 @@ import {
PlusIcon,
SparklesIcon,
RocketIcon,
TargetIcon,
XIcon,
ZapIcon,
} from "lucide-react";
@ -63,10 +64,9 @@ import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
import { isHiddenFromUIMessage } from "@/core/messages/utils";
import { useModels } from "@/core/models/hooks";
import type { Skill } from "@/core/skills";
import { useSkills } from "@/core/skills/hooks";
import { useSuggestionsConfig } from "@/core/suggestions/hooks";
import type { AgentThreadContext } from "@/core/threads";
import type { AgentThreadContext, GoalState } from "@/core/threads";
import { textOfMessage } from "@/core/threads/utils";
import {
formatUploadSize,
@ -95,68 +95,27 @@ import {
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import {
abortGoalRequest,
beginGoalRequest,
createGoalRequestState,
findSuggestionTemplatePlaceholder,
finishGoalRequest,
getInputSubmitAction,
getLeadingSlashSkillQuery,
getMatchingSkillSuggestions,
type GoalCommand,
isAbortError,
isCurrentGoalRequest,
readGoalResponseError,
type SlashSuggestion,
} from "./input-box-helpers";
import { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip";
type InputMode = "flash" | "thinking" | "pro" | "ultra";
const MAX_SKILL_SUGGESTIONS = 6;
const SUGGESTION_TEMPLATE_PLACEHOLDER_PATTERN =
/\[(?:主题|来源|topic|source)\]/i;
function findSuggestionTemplatePlaceholder(text: string) {
const match = SUGGESTION_TEMPLATE_PLACEHOLDER_PATTERN.exec(text);
if (!match) {
return null;
}
return {
start: match.index,
end: match.index + match[0].length,
};
}
function getLeadingSlashSkillQuery(value: string): string | null {
if (!value.startsWith("/")) {
return null;
}
const query = value.slice(1);
if (query.includes("/") || /\s/.test(query)) {
return null;
}
return query;
}
function getMatchingSkillSuggestions(skills: Skill[], query: string): Skill[] {
const normalizedQuery = query.toLowerCase();
return skills
.map((skill, index) => ({
skill,
index,
name: skill.name.toLowerCase(),
}))
.filter(({ skill, name }) => {
if (!skill.enabled) {
return false;
}
return !normalizedQuery || name.includes(normalizedQuery);
})
.sort((a, b) => {
const aStartsWith = a.name.startsWith(normalizedQuery);
const bStartsWith = b.name.startsWith(normalizedQuery);
if (aStartsWith !== bStartsWith) {
return aStartsWith ? -1 : 1;
}
return a.index - b.index;
})
.slice(0, MAX_SKILL_SUGGESTIONS)
.map(({ skill }) => skill);
}
function getResolvedMode(
mode: InputMode | undefined,
supportsThinking: boolean,
@ -182,6 +141,7 @@ export function InputBox({
initialValue,
onContextChange,
onFollowupsVisibilityChange,
onGoalChange,
onSubmit,
onStop,
...props
@ -215,6 +175,7 @@ export function InputBox({
},
) => void;
onFollowupsVisibilityChange?: (visible: boolean) => void;
onGoalChange?: (goal: GoalState | null) => void;
onSubmit?: (message: PromptInputMessage) => void | Promise<void>;
onStop?: () => void;
}) {
@ -230,6 +191,7 @@ export function InputBox({
const { data: uploadLimits } = useUploadLimits(threadId);
const promptRootRef = useRef<HTMLDivElement | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const goalRequestStateRef = useRef(createGoalRequestState());
const promptHistoryIndexRef = useRef<number | null>(null);
const promptHistoryDraftRef = useRef("");
@ -251,6 +213,16 @@ export function InputBox({
const [pendingSuggestion, setPendingSuggestion] = useState<string | null>(
null,
);
const builtinSlashCommands = useMemo<SlashSuggestion[]>(
() => [
{
name: "goal",
description: t.inputBox.goalCommandDescription,
kind: "builtin",
},
],
[t.inputBox.goalCommandDescription],
);
const reportUploadLimitViolations = useCallback(
(violations: UploadLimitViolation[]) => {
@ -382,6 +354,11 @@ export function InputBox({
promptHistoryDraftRef.current = "";
}, [threadId]);
useEffect(() => {
const goalRequestState = goalRequestStateRef.current;
return () => abortGoalRequest(goalRequestState);
}, [threadId]);
useEffect(() => {
const currentIndex = promptHistoryIndexRef.current;
if (currentIndex !== null && currentIndex >= promptHistory.length) {
@ -435,20 +412,131 @@ export function InputBox({
[onContextChange, context],
);
const handleSubmit = useCallback(
const handleGoalCommand = useCallback(
async (command: GoalCommand): Promise<boolean> => {
const request = beginGoalRequest(goalRequestStateRef.current, threadId);
const signal = request.controller.signal;
try {
let goal: GoalState | null = null;
if (command.kind === "status") {
const response = await fetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(
threadId,
)}/goal`,
{ method: "GET", signal },
);
if (!response.ok) {
throw new Error(await readGoalResponseError(response));
}
goal =
((await response.json()) as { goal?: GoalState | null }).goal ??
null;
if (
!isCurrentGoalRequest(
goalRequestStateRef.current,
request,
threadId,
)
) {
return false;
}
const objective = goal?.objective;
toast.info(
objective !== undefined
? // Function replacer so a goal containing `$&`/`$1` isn't
// interpreted as a replacement pattern.
t.inputBox.goalActive.replace("{goal}", () => objective)
: t.inputBox.goalNone,
);
onGoalChange?.(goal);
} else if (command.kind === "clear") {
const response = await fetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(
threadId,
)}/goal`,
{ method: "DELETE", signal },
);
if (!response.ok) {
throw new Error(await readGoalResponseError(response));
}
if (
!isCurrentGoalRequest(
goalRequestStateRef.current,
request,
threadId,
)
) {
return false;
}
toast.success(t.inputBox.goalCleared);
onGoalChange?.(null);
} else {
const response = await fetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(
threadId,
)}/goal`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ objective: command.objective }),
signal,
},
);
if (!response.ok) {
throw new Error(await readGoalResponseError(response));
}
goal =
((await response.json()) as { goal?: GoalState | null }).goal ??
null;
if (
!isCurrentGoalRequest(
goalRequestStateRef.current,
request,
threadId,
)
) {
return false;
}
toast.success(t.inputBox.goalSet);
onGoalChange?.(goal);
}
textInput.setInput("");
return true;
} catch (error) {
if (
isAbortError(error) ||
!isCurrentGoalRequest(goalRequestStateRef.current, request, threadId)
) {
return false;
}
toast.error(
error instanceof Error ? error.message : t.inputBox.goalFailed,
);
return false;
} finally {
finishGoalRequest(goalRequestStateRef.current, request);
}
},
[
onGoalChange,
t.inputBox.goalActive,
t.inputBox.goalCleared,
t.inputBox.goalFailed,
t.inputBox.goalNone,
t.inputBox.goalSet,
textInput,
threadId,
],
);
const submitThreadMessage = useCallback(
(message: PromptInputMessage) => {
if (status === "streaming") {
onStop?.();
return;
}
if (!message.text.trim() && message.files.length === 0) {
return;
}
const files = message.files.flatMap((file) =>
file.file instanceof File ? [file.file] : [],
);
const uploadValidation = validateUploadLimits([], files, uploadLimits);
if (uploadValidation.violations.length > 0) {
reportUploadLimitViolations(uploadValidation.violations);
return Promise.reject(new Error("Attachment limits exceeded."));
}
const placeholder = findSuggestionTemplatePlaceholder(message.text);
@ -496,15 +584,54 @@ export function InputBox({
context,
onContextChange,
onSubmit,
onStop,
reportUploadLimitViolations,
resolvedModelName,
selectedModel?.supports_thinking,
status,
t.inputBox.suggestionPlaceholderRequired,
uploadLimits,
],
);
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
const submitAction = getInputSubmitAction({
text: message.text,
fileCount: message.files.length,
status,
});
if (submitAction.kind === "goal") {
promptHistoryIndexRef.current = null;
promptHistoryDraftRef.current = "";
setFollowups([]);
setFollowupsHidden(false);
setFollowupsLoading(false);
const saved = await handleGoalCommand(submitAction.command);
// Only start a run when a goal was actually saved; status/clear never run.
if (
saved &&
submitAction.command.kind === "set" &&
status !== "streaming"
) {
return submitThreadMessage({
...message,
text: submitAction.command.objective,
files: [],
});
}
return;
}
if (submitAction.kind === "stop") {
onStop?.();
return;
}
if (submitAction.kind === "empty") {
return;
}
return submitThreadMessage(message);
},
[handleGoalCommand, onStop, status, submitThreadMessage],
);
const requestFormSubmit = useCallback(() => {
const form = promptRootRef.current?.querySelector("form");
form?.requestSubmit();
@ -564,8 +691,12 @@ export function InputBox({
() =>
slashSkillQuery === null
? []
: getMatchingSkillSuggestions(skills, slashSkillQuery),
[skills, slashSkillQuery],
: getMatchingSkillSuggestions(
skills,
slashSkillQuery,
builtinSlashCommands,
),
[builtinSlashCommands, skills, slashSkillQuery],
);
const showSkillSuggestions =
!disabled &&
@ -579,8 +710,8 @@ export function InputBox({
}, [slashSkillQuery, skillSuggestions.length]);
const applySkillSuggestion = useCallback(
(skill: Skill) => {
const nextValue = `/${skill.name} `;
(suggestion: SlashSuggestion) => {
const nextValue = `/${suggestion.name} `;
textInput.setInput(nextValue);
setDismissedSkillSuggestionValue(nextValue);
requestAnimationFrame(() => {
@ -885,7 +1016,7 @@ export function InputBox({
className="bg-popover/95 text-popover-foreground border-border max-h-72 overflow-y-auto rounded-xl border p-1 shadow-lg backdrop-blur-sm"
role="listbox"
>
{skillSuggestions.map((skill, index) => {
{skillSuggestions.map((suggestion, index) => {
const selected = index === skillSuggestionIndex;
return (
<button
@ -896,21 +1027,25 @@ export function InputBox({
? "bg-accent text-accent-foreground"
: "text-popover-foreground hover:bg-accent/70 hover:text-accent-foreground",
)}
key={skill.name}
onClick={() => applySkillSuggestion(skill)}
key={`${suggestion.kind}:${suggestion.name}`}
onClick={() => applySkillSuggestion(suggestion)}
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={() => setSkillSuggestionIndex(index)}
role="option"
type="button"
>
<SparklesIcon className="text-muted-foreground size-4 shrink-0" />
{suggestion.kind === "builtin" ? (
<TargetIcon className="text-muted-foreground size-4 shrink-0" />
) : (
<SparklesIcon className="text-muted-foreground size-4 shrink-0" />
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
/{skill.name}
/{suggestion.name}
</span>
{skill.description && (
{suggestion.description && (
<span className="text-muted-foreground block truncate text-xs">
{skill.description}
{suggestion.description}
</span>
)}
</span>

View File

@ -0,0 +1,68 @@
import { useEffect, useRef, useState } from "react";
import type { GoalState } from "@/core/threads/types";
import { goalReconciliationKey } from "./goal-status-helpers";
export type UseActiveGoalResult = {
/** The goal to render — the optimistic override while set, else server state. */
activeGoal: GoalState | null;
hasGoal: boolean;
/** Apply an optimistic goal after a `/goal` command (or `null` to hide it). */
setLocalGoal: (goal: GoalState | null) => void;
};
export function resolveActiveGoal(
localGoal: GoalState | null | undefined,
serverGoal: GoalState | null | undefined,
): GoalState | null {
return localGoal !== undefined ? localGoal : (serverGoal ?? null);
}
export function shouldResetLocalGoalOverride({
serverGoalProvided,
threadChanged,
}: {
serverGoalProvided: boolean;
threadChanged: boolean;
}): boolean {
if (threadChanged) {
return true;
}
return serverGoalProvided;
}
/**
* Reconciles the optimistic `/goal`-command result with the server's goal state.
*
* A `/goal` command updates the UI immediately via `setLocalGoal`, but that
* override is dropped as soon as the server explicitly reports goal state
* switching threads, a new `continuation_count`, or a cleared goal. A stream
* chunk that omits the `goal` field is not treated as a clear, because
* clarification interrupts can publish partial values while the active goal is
* still present in the checkpoint.
*/
export function useActiveGoal(
threadId: string,
serverGoal: GoalState | null | undefined,
): UseActiveGoalResult {
const [localGoal, setLocalGoal] = useState<GoalState | null | undefined>(
undefined,
);
const previousThreadIdRef = useRef(threadId);
const serverGoalProvided = serverGoal !== undefined;
const serverGoalKey = serverGoalProvided
? goalReconciliationKey(serverGoal)
: "missing";
useEffect(() => {
const threadChanged = previousThreadIdRef.current !== threadId;
previousThreadIdRef.current = threadId;
if (shouldResetLocalGoalOverride({ serverGoalProvided, threadChanged })) {
setLocalGoal(undefined);
}
}, [serverGoalKey, serverGoalProvided, threadId]);
const activeGoal = resolveActiveGoal(localGoal, serverGoal);
return { activeGoal, hasGoal: Boolean(activeGoal), setLocalGoal };
}

View File

@ -118,6 +118,16 @@ export const enUS: Translations = {
followupConfirmReplace: "Replace & send",
suggestionPlaceholderRequired:
"Replace the suggestion placeholder before sending.",
goalCommandDescription: "Set, show, or clear an active goal",
goalLabel: "Goal",
goalContinuing: "Continuing {count}/{max}",
goalContinuationTooltip:
"Auto-continued {count}/{max} times toward the goal; stops at the limit.",
goalSet: "Goal set.",
goalCleared: "Goal cleared.",
goalNone: "No active goal.",
goalActive: "Active goal: {goal}",
goalFailed: "Goal command failed.",
suggestions: [
{
suggestion: "Write",

View File

@ -95,6 +95,15 @@ export interface Translations {
followupConfirmAppend: string;
followupConfirmReplace: string;
suggestionPlaceholderRequired: string;
goalCommandDescription: string;
goalLabel: string;
goalContinuing: string;
goalContinuationTooltip: string;
goalSet: string;
goalCleared: string;
goalNone: string;
goalActive: string;
goalFailed: string;
suggestions: {
suggestion: string;
prompt: string;

View File

@ -112,6 +112,16 @@ export const zhCN: Translations = {
followupConfirmAppend: "追加并发送",
followupConfirmReplace: "替换并发送",
suggestionPlaceholderRequired: "发送前请先填写建议模板中的占位内容。",
goalCommandDescription: "设置、查看或清除当前目标",
goalLabel: "目标",
goalContinuing: "续跑中 {count}/{max}",
goalContinuationTooltip:
"为达成目标已自动续跑 {count}/{max} 次,达上限后自动停止",
goalSet: "目标已设置。",
goalCleared: "目标已清除。",
goalNone: "当前没有目标。",
goalActive: "当前目标:{goal}",
goalFailed: "目标命令执行失败。",
suggestions: [
{
suggestion: "写作",

View File

@ -2,11 +2,39 @@ import type { Message, Thread } from "@langchain/langgraph-sdk";
import type { Todo } from "../todos";
export interface GoalState {
objective: string;
status: "active";
created_at: string;
updated_at: string;
continuation_count: number;
max_continuations: number;
no_progress_count: number;
max_no_progress_continuations: number;
last_evaluation?: {
satisfied: boolean;
blocker:
| "none"
| "missing_evidence"
| "needs_user_input"
| "run_failed"
| "external_wait"
| "goal_not_met_yet";
reason: string;
evidence_summary?: string;
run_id?: string;
evaluated_at?: string;
progress_key?: string;
stand_down_reason?: string;
};
}
export interface AgentThreadState extends Record<string, unknown> {
title: string;
messages: Message[];
artifacts?: string[];
todos?: Todo[];
goal?: GoalState | null;
}
export interface AgentThreadContext extends Record<string, unknown> {

View File

@ -44,6 +44,73 @@ test.describe("Chat workspace", () => {
await expect(textarea).toHaveValue("/data-analysis ");
});
test("goal command sets a goal and starts an agent run", async ({ page }) => {
let streamCalls = 0;
await page.goto("/workspace/chats/new");
await page.route("**/runs/stream", (route) => {
streamCalls += 1;
return route.fallback();
});
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("/go");
await expect(page.getByRole("option", { name: /goal/i })).toBeVisible();
await textarea.fill("/goal finish all tests");
await textarea.press("Enter");
await expect(
page.locator("span.font-medium", { hasText: "finish all tests" }),
).toBeVisible();
await expect.poll(() => streamCalls).toBe(1);
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible();
});
test("goal command keeps the welcome header clear of the goal status", async ({
page,
}) => {
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill(
"/goal finish a small repo check and report the result",
);
await textarea.press("Enter");
const goal = page.locator("span.font-medium", {
hasText: "finish a small repo check",
});
await expect(goal).toBeVisible();
await expect(page.getByText(/welcome to/i)).toBeHidden();
const overlaps = await page.evaluate(() => {
const welcome = [...document.querySelectorAll("p")].find((el) =>
el.textContent?.toLowerCase().includes("welcome to"),
);
const goal = [...document.querySelectorAll("span")].find((el) =>
el.textContent?.includes(
"finish a small repo check and report the result",
),
);
if (!welcome || !goal) {
return false;
}
const welcomeRect = welcome.getBoundingClientRect();
const goalRect = goal.getBoundingClientRect();
return !(
welcomeRect.right < goalRect.left ||
goalRect.right < welcomeRect.left ||
welcomeRect.bottom < goalRect.top ||
goalRect.bottom < welcomeRect.top
);
});
expect(overlaps).toBe(false);
});
test("uses arrow keys to navigate skill suggestions before prompt history", async ({
page,
}) => {

View File

@ -35,6 +35,7 @@ export type MockThread = {
metadata?: Record<string, unknown>;
messages?: unknown[];
artifacts?: string[];
goal?: Record<string, unknown> | null;
};
export type MockAgent = {
@ -133,7 +134,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
...(thread.agent_name ? { agent_name: thread.agent_name } : {}),
},
status: "idle",
values: { title: thread.title ?? "Untitled" },
values: { title: thread.title ?? "Untitled", goal: thread.goal ?? null },
});
// Auth — keep workspace tests independent from a real gateway session.
@ -293,6 +294,63 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
return route.fallback();
});
void page.route(/\/api\/threads\/[^/]+\/goal$/, async (route) => {
const threadId = decodeURIComponent(
new URL(route.request().url()).pathname.split("/").at(-2) ?? "",
);
let matchingThread = threads.find(
(thread) => thread.thread_id === threadId,
);
if (route.request().method() === "GET") {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ goal: matchingThread?.goal ?? null }),
});
}
if (route.request().method() === "DELETE") {
if (matchingThread) {
matchingThread.goal = null;
}
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ goal: null }),
});
}
if (route.request().method() === "PUT") {
const payload = route.request().postDataJSON() as {
objective?: string;
};
const goal = {
objective: payload.objective ?? "",
status: "active",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
continuation_count: 0,
max_continuations: 8,
no_progress_count: 0,
max_no_progress_continuations: 2,
};
matchingThread ??= {
thread_id: threadId,
title: "New Chat",
updated_at: new Date().toISOString(),
};
upsertThread({ ...matchingThread, goal });
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ goal }),
});
}
return route.fallback();
});
void page.route("**/api/threads/*/uploads/limits", (route) => {
if (route.request().method() === "GET") {
return route.fulfill({
@ -318,6 +376,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
{
values: {
title: matchingThread.title ?? "Untitled",
goal: matchingThread.goal ?? null,
messages: matchingThread.messages ?? [
{
type: "human",
@ -360,6 +419,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
body: JSON.stringify({
values: {
title: matchingThread?.title ?? "Untitled",
goal: matchingThread?.goal ?? null,
messages: matchingThread
? (matchingThread.messages ?? [
{
@ -445,13 +505,20 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
// Run stream — returns a minimal SSE response with an AI message
const handleMockRunStream = (route: Route) => {
const requestUrl = route.request().url();
const matchingThread = threads.find((thread) =>
requestUrl.includes(encodeURIComponent(thread.thread_id)),
);
const fallbackGoal = threads.find((thread) => thread.goal)?.goal ?? null;
const goal = matchingThread?.goal ?? fallbackGoal;
upsertThread({
thread_id: MOCK_THREAD_ID,
title: "New Chat",
updated_at: new Date().toISOString(),
goal,
messages: mockStreamMessages(),
});
return handleRunStream(route);
return handleRunStream(route, { goal });
};
void page.route("**/api/langgraph/runs/stream", handleMockRunStream);
@ -554,7 +621,10 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
* Build a minimal SSE stream that the LangGraph SDK can parse.
* The stream returns a single AI message: "Hello from DeerFlow!".
*/
export function handleRunStream(route: Route) {
export function handleRunStream(
route: Route,
values: Record<string, unknown> = {},
) {
const events = [
{
event: "metadata",
@ -563,6 +633,7 @@ export function handleRunStream(route: Route) {
{
event: "values",
data: {
...values,
messages: mockStreamMessages(),
},
},

View File

@ -0,0 +1,97 @@
import { describe, expect, it } from "@rstest/core";
import {
getGoalContinuationDisplay,
goalReconciliationKey,
} from "@/components/workspace/goal-status-helpers";
import type { GoalState } from "@/core/threads/types";
function makeGoal(overrides: Partial<GoalState> = {}): GoalState {
return {
objective: "ship it",
status: "active",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
continuation_count: 0,
max_continuations: 8,
no_progress_count: 0,
max_no_progress_continuations: 2,
...overrides,
};
}
describe("getGoalContinuationDisplay", () => {
it("hides the counter before the agent has auto-continued", () => {
expect(
getGoalContinuationDisplay({
continuation_count: 0,
max_continuations: 8,
}),
).toBeNull();
});
it("shows count and max once continuation has started", () => {
expect(
getGoalContinuationDisplay({
continuation_count: 1,
max_continuations: 8,
}),
).toEqual({ count: 1, max: 8 });
expect(
getGoalContinuationDisplay({
continuation_count: 8,
max_continuations: 8,
}),
).toEqual({ count: 8, max: 8 });
});
it("treats missing or negative counts as hidden", () => {
expect(
getGoalContinuationDisplay({
continuation_count: -1,
max_continuations: 8,
}),
).toBeNull();
expect(
getGoalContinuationDisplay({
continuation_count: undefined as unknown as number,
max_continuations: undefined as unknown as number,
}),
).toBeNull();
});
});
describe("goalReconciliationKey", () => {
it("returns a constant sentinel when there is no goal", () => {
expect(goalReconciliationKey(null)).toBe("none");
});
it("is stable for an unchanged goal", () => {
expect(goalReconciliationKey(makeGoal())).toBe(
goalReconciliationKey(makeGoal()),
);
});
it("changes when the agent auto-continues", () => {
expect(goalReconciliationKey(makeGoal({ continuation_count: 0 }))).not.toBe(
goalReconciliationKey(
makeGoal({
continuation_count: 1,
updated_at: "2026-01-01T00:01:00Z",
}),
),
);
});
it("changes when a different goal is set", () => {
expect(goalReconciliationKey(makeGoal({ objective: "a" }))).not.toBe(
goalReconciliationKey(makeGoal({ objective: "b" })),
);
});
it("distinguishes a cleared goal from an active one", () => {
expect(goalReconciliationKey(null)).not.toBe(
goalReconciliationKey(makeGoal()),
);
});
});

View File

@ -0,0 +1,260 @@
import { describe, expect, it } from "@rstest/core";
import {
abortGoalRequest,
beginGoalRequest,
createGoalRequestState,
findSuggestionTemplatePlaceholder,
finishGoalRequest,
getInputSubmitAction,
getLeadingSlashSkillQuery,
getMatchingSkillSuggestions,
isAbortError,
isCurrentGoalRequest,
parseGoalCommand,
readGoalResponseError,
type SlashSuggestion,
} from "@/components/workspace/input-box-helpers";
import type { Skill } from "@/core/skills";
function makeSkill(name: string, enabled = true): Skill {
return {
name,
description: `${name} description`,
enabled,
} as Skill;
}
// Builtin command names are bare (no leading slash); the composer renders them
// as `/${name}`. Mirror that shape here.
const builtins: SlashSuggestion[] = [
{
name: "goal",
description: "Set, show, or clear an active goal",
kind: "builtin",
},
{ name: "new", description: "Start a new thread", kind: "builtin" },
];
describe("parseGoalCommand", () => {
it("returns status for a bare /goal", () => {
expect(parseGoalCommand("/goal")).toEqual({ kind: "status" });
expect(parseGoalCommand(" /goal ")).toEqual({ kind: "status" });
});
it("treats clear/reset/off as clear (case-insensitive)", () => {
expect(parseGoalCommand("/goal clear")).toEqual({ kind: "clear" });
expect(parseGoalCommand("/GOAL Reset")).toEqual({ kind: "clear" });
expect(parseGoalCommand("/goal off")).toEqual({ kind: "clear" });
});
it("captures the objective for /goal <text>", () => {
expect(parseGoalCommand("/goal ship the feature")).toEqual({
kind: "set",
objective: "ship the feature",
});
});
it("returns null when the input is not a /goal command", () => {
expect(parseGoalCommand("/goalkeeper do thing")).toBeNull();
expect(parseGoalCommand("hello")).toBeNull();
expect(parseGoalCommand("/new")).toBeNull();
});
});
describe("getInputSubmitAction", () => {
it("handles /goal commands before the streaming stop shortcut", () => {
expect(
getInputSubmitAction({
text: "/goal ",
fileCount: 0,
status: "streaming",
}),
).toEqual({ kind: "goal", command: { kind: "status" } });
});
it("handles /goal set commands before the streaming stop shortcut", () => {
expect(
getInputSubmitAction({
text: "/goal finish the work",
fileCount: 0,
status: "streaming",
}),
).toEqual({
kind: "goal",
command: { kind: "set", objective: "finish the work" },
});
});
it("keeps ordinary streaming submits as stop", () => {
expect(
getInputSubmitAction({
text: "hello",
fileCount: 0,
status: "streaming",
}),
).toEqual({ kind: "stop" });
});
it("does not treat /goal text with attachments as a goal command", () => {
expect(
getInputSubmitAction({
text: "/goal ",
fileCount: 1,
status: "ready",
}),
).toEqual({ kind: "message" });
});
it("ignores empty ready submits", () => {
expect(
getInputSubmitAction({
text: " ",
fileCount: 0,
status: "ready",
}),
).toEqual({ kind: "empty" });
});
});
describe("getLeadingSlashSkillQuery", () => {
it("returns the query for a leading slash token", () => {
expect(getLeadingSlashSkillQuery("/rev")).toBe("rev");
expect(getLeadingSlashSkillQuery("/")).toBe("");
});
it("returns null when there is no leading slash or the token is not bare", () => {
expect(getLeadingSlashSkillQuery("rev")).toBeNull();
expect(getLeadingSlashSkillQuery("/rev now")).toBeNull();
expect(getLeadingSlashSkillQuery("/a/b")).toBeNull();
});
});
describe("getMatchingSkillSuggestions", () => {
it("excludes disabled skills and ranks prefix matches first", () => {
const skills = [
makeSkill("deep-research"),
makeSkill("review"),
makeSkill("reviewer-disabled", false),
];
const result = getMatchingSkillSuggestions(skills, "rev", []);
expect(result.map((s) => s.name)).toEqual(["review"]);
expect(result.every((s) => s.kind === "skill")).toBe(true);
});
it("includes matching builtin commands after skills", () => {
const result = getMatchingSkillSuggestions(
[makeSkill("goal-helper")],
"goal",
builtins,
);
expect(result.map((s) => s.name)).toContain("goal-helper");
expect(result.map((s) => s.name)).toContain("goal");
});
it("excludes skills that collide with builtin command names", () => {
const result = getMatchingSkillSuggestions(
[makeSkill("goal"), makeSkill("goal-helper")],
"goal",
builtins,
);
expect(result.map((s) => `${s.kind}:${s.name}`)).toEqual([
"skill:goal-helper",
"builtin:goal",
]);
});
it("caps the number of suggestions", () => {
const skills = Array.from({ length: 10 }, (_, i) =>
makeSkill(`skill-${i}`),
);
const result = getMatchingSkillSuggestions(skills, "", []);
expect(result.length).toBeLessThanOrEqual(6);
});
});
describe("readGoalResponseError", () => {
it("returns the detail string when present", async () => {
const response = {
status: 422,
json: async () => ({ detail: "Goal objective must not be empty." }),
} as unknown as Response;
expect(await readGoalResponseError(response)).toBe(
"Goal objective must not be empty.",
);
});
it("falls back to the HTTP status when detail is missing or unparseable", async () => {
const noDetail = {
status: 500,
json: async () => ({}),
} as unknown as Response;
expect(await readGoalResponseError(noDetail)).toBe("HTTP 500");
const broken = {
status: 503,
json: async () => {
throw new Error("not json");
},
} as unknown as Response;
expect(await readGoalResponseError(broken)).toBe("HTTP 503");
});
});
describe("goal request lifecycle", () => {
it("aborts a pending goal request when the thread changes and blocks stale updates", () => {
const state = createGoalRequestState();
const first = beginGoalRequest(state, "thread-1");
const updates: string[] = [];
abortGoalRequest(state);
const second = beginGoalRequest(state, "thread-2");
if (isCurrentGoalRequest(state, first, "thread-1")) {
updates.push("thread-1");
}
if (isCurrentGoalRequest(state, second, "thread-2")) {
updates.push("thread-2");
}
expect(first.controller.signal.aborted).toBe(true);
expect(second.controller.signal.aborted).toBe(false);
expect(updates).toEqual(["thread-2"]);
});
it("does not let an older request finish a newer one", () => {
const state = createGoalRequestState();
const first = beginGoalRequest(state, "thread-1");
const second = beginGoalRequest(state, "thread-1");
finishGoalRequest(state, first);
expect(isCurrentGoalRequest(state, second, "thread-1")).toBe(true);
});
it("recognizes abort-shaped errors", () => {
expect(isAbortError(new DOMException("aborted", "AbortError"))).toBe(true);
expect(
isAbortError(Object.assign(new Error("aborted"), { name: "AbortError" })),
).toBe(true);
expect(isAbortError(new Error("other"))).toBe(false);
});
});
describe("findSuggestionTemplatePlaceholder", () => {
it("locates a topic/source placeholder", () => {
const found = findSuggestionTemplatePlaceholder("Research [topic] deeply");
expect(found).not.toBeNull();
expect(
found && "Research [topic] deeply".slice(found.start, found.end),
).toBe("[topic]");
});
it("returns null when no placeholder is present", () => {
expect(findSuggestionTemplatePlaceholder("no placeholder here")).toBeNull();
});
});

View File

@ -0,0 +1,62 @@
import { describe, expect, it } from "@rstest/core";
import {
resolveActiveGoal,
shouldResetLocalGoalOverride,
} from "@/components/workspace/use-active-goal";
import type { GoalState } from "@/core/threads/types";
function makeGoal(overrides: Partial<GoalState> = {}): GoalState {
return {
objective: "ship the landing page",
status: "active",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
continuation_count: 0,
max_continuations: 8,
no_progress_count: 0,
max_no_progress_continuations: 2,
...overrides,
};
}
describe("resolveActiveGoal", () => {
it("keeps the optimistic goal when stream values omit the goal field", () => {
const localGoal = makeGoal();
expect(resolveActiveGoal(localGoal, undefined)).toBe(localGoal);
});
it("falls back to null when neither local nor server state has a goal", () => {
expect(resolveActiveGoal(undefined, undefined)).toBeNull();
});
});
describe("shouldResetLocalGoalOverride", () => {
it("does not reset an optimistic goal when the same thread omits goal from stream values", () => {
expect(
shouldResetLocalGoalOverride({
serverGoalProvided: false,
threadChanged: false,
}),
).toBe(false);
});
it("resets an optimistic goal when the server explicitly clears the goal", () => {
expect(
shouldResetLocalGoalOverride({
serverGoalProvided: true,
threadChanged: false,
}),
).toBe(true);
});
it("resets an optimistic goal on real thread navigation", () => {
expect(
shouldResetLocalGoalOverride({
serverGoalProvided: false,
threadChanged: true,
}),
).toBe(true);
});
});