From 25ea6970a6d445d65ce6dd005631df3ab8ccfe17 Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Fri, 3 Jul 2026 08:49:33 +0800 Subject: [PATCH] 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 * 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 * 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 * 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 --- README.md | 22 +- backend/AGENTS.md | 16 +- backend/app/channels/commands.py | 1 + backend/app/channels/manager.py | 64 ++ backend/app/channels/telegram.py | 1 + backend/app/gateway/routers/suggestions.py | 56 +- backend/app/gateway/routers/threads.py | 100 +++ backend/docs/TUI.md | 10 +- .../harness/deerflow/agents/goal_state.py | 31 + .../harness/deerflow/agents/thread_state.py | 9 + backend/packages/harness/deerflow/client.py | 73 +- .../packages/harness/deerflow/runtime/goal.py | 522 +++++++++++++ .../harness/deerflow/runtime/runs/worker.py | 419 ++++++++++- .../packages/harness/deerflow/skills/slash.py | 2 +- backend/packages/harness/deerflow/tui/app.py | 44 +- .../harness/deerflow/tui/command_registry.py | 1 + .../harness/deerflow/utils/llm_text.py | 49 ++ backend/tests/test_channels.py | 187 ++++- backend/tests/test_client.py | 36 + .../tests/test_gateway_lifespan_shutdown.py | 5 +- backend/tests/test_goal_runtime.py | 236 ++++++ backend/tests/test_goal_worker.py | 689 ++++++++++++++++++ backend/tests/test_slash_skills.py | 2 +- backend/tests/test_thread_state_reducers.py | 25 + backend/tests/test_threads_router.py | 73 ++ backend/tests/test_tui_app.py | 109 +++ backend/tests/test_tui_command_registry.py | 15 + frontend/AGENTS.md | 7 +- .../[agent_name]/chats/[thread_id]/page.tsx | 28 +- .../app/workspace/chats/[thread_id]/page.tsx | 28 +- .../workspace/goal-status-helpers.ts | 47 ++ .../src/components/workspace/goal-status.tsx | 51 ++ .../components/workspace/input-box-helpers.ts | 232 ++++++ .../src/components/workspace/input-box.tsx | 293 ++++++-- .../components/workspace/use-active-goal.ts | 68 ++ frontend/src/core/i18n/locales/en-US.ts | 10 + frontend/src/core/i18n/locales/types.ts | 9 + frontend/src/core/i18n/locales/zh-CN.ts | 10 + frontend/src/core/threads/types.ts | 28 + frontend/tests/e2e/chat.spec.ts | 67 ++ frontend/tests/e2e/utils/mock-api.ts | 77 +- .../workspace/goal-status-helpers.test.ts | 97 +++ .../workspace/input-box-helpers.test.ts | 260 +++++++ .../workspace/use-active-goal.test.ts | 62 ++ 44 files changed, 3963 insertions(+), 208 deletions(-) create mode 100644 backend/packages/harness/deerflow/agents/goal_state.py create mode 100644 backend/packages/harness/deerflow/runtime/goal.py create mode 100644 backend/packages/harness/deerflow/utils/llm_text.py create mode 100644 backend/tests/test_goal_runtime.py create mode 100644 backend/tests/test_goal_worker.py create mode 100644 frontend/src/components/workspace/goal-status-helpers.ts create mode 100644 frontend/src/components/workspace/goal-status.tsx create mode 100644 frontend/src/components/workspace/input-box-helpers.ts create mode 100644 frontend/src/components/workspace/use-active-goal.ts create mode 100644 frontend/tests/unit/components/workspace/goal-status-helpers.test.ts create mode 100644 frontend/tests/unit/components/workspace/input-box-helpers.test.ts create mode 100644 frontend/tests/unit/components/workspace/use-active-goal.test.ts diff --git a/README.md b/README.md index 1fd84ffc1..ee53d158d 100644 --- a/README.md +++ b/README.md @@ -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 ` 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 ` 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. diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 16041ce28..a310f317a 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -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 (`...`, 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 | diff --git a/backend/app/channels/commands.py b/backend/app/channels/commands.py index 86e4e9105..2a2c5acad 100644 --- a/backend/app/channels/commands.py +++ b/backend/app/channels/commands.py @@ -11,6 +11,7 @@ from __future__ import annotations KNOWN_CHANNEL_COMMANDS: frozenset[str] = frozenset( { "/bootstrap", + "/goal", "/new", "/status", "/models", diff --git a/backend/app/channels/manager.py b/backend/app/channels/manager.py index 519c28235..1a07d8f90 100644 --- a/backend/app/channels/manager.py +++ b/backend/app/channels/manager.py @@ -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 diff --git a/backend/app/channels/telegram.py b/backend/app/channels/telegram.py index 28dd0291f..14ee28a4f 100644 --- a/backend/app/channels/telegram.py +++ b/backend/app/channels/telegram.py @@ -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 diff --git a/backend/app/gateway/routers/suggestions.py b/backend/app/gateway/routers/suggestions.py index 2bbe8dae2..c672ce5b2 100644 --- a/backend/app/gateway/routers/suggestions.py +++ b/backend/app/gateway/routers/suggestions.py @@ -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 ... block (case-insensitive, spans newlines). -_THINK_BLOCK_RE = re.compile(r"]*>.*?", re.IGNORECASE | re.DOTALL) -# Matches a dangling, unclosed (model truncated at max_tokens mid-thought). -_OPEN_THINK_RE = re.compile(r"]*>", re.IGNORECASE) - - -def _strip_think_blocks(text: str) -> str: - """Remove reasoning-model ``...`` blocks from the response. - - Reasoning models such as MiniMax-M3 inline their chain-of-thought into the - message ``content`` wrapped in ``...`` (``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 (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: diff --git a/backend/app/gateway/routers/threads.py b/backend/app/gateway/routers/threads.py index a2549847b..eff0da400 100644 --- a/backend/app/gateway/routers/threads.py +++ b/backend/app/gateway/routers/threads.py @@ -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) diff --git a/backend/docs/TUI.md b/backend/docs/TUI.md index a65d722bf..aa8b13513 100644 --- a/backend/docs/TUI.md +++ b/backend/docs/TUI.md @@ -60,10 +60,12 @@ instead of hanging. ### Slash commands -`/help` `/new` `/threads` (`/switch`) `/model` `/skills` `/tools` `/mcp` -`/memory` `/uploads` `/usage` `/config` `/quit`, plus `/ 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 +`/ task` to activate any enabled skill for the current turn (same +semantics as elsewhere in DeerFlow). `/model` and `/threads` open modal pickers. +Use `/goal ` to set the active thread goal, `/goal` to show it, and +`/goal clear` to clear it. ## Architecture diff --git a/backend/packages/harness/deerflow/agents/goal_state.py b/backend/packages/harness/deerflow/agents/goal_state.py new file mode 100644 index 000000000..9fdd42680 --- /dev/null +++ b/backend/packages/harness/deerflow/agents/goal_state.py @@ -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]] diff --git a/backend/packages/harness/deerflow/agents/thread_state.py b/backend/packages/harness/deerflow/agents/thread_state.py index 7287b2a55..48000ac6a 100644 --- a/backend/packages/harness/deerflow/agents/thread_state.py +++ b/backend/packages/harness/deerflow/agents/thread_state.py @@ -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] diff --git a/backend/packages/harness/deerflow/client.py b/backend/packages/harness/deerflow/client.py index 82ff61db7..6074f9b5c 100644 --- a/backend/packages/harness/deerflow/client.py +++ b/backend/packages/harness/deerflow/client.py @@ -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 = [] diff --git a/backend/packages/harness/deerflow/runtime/goal.py b/backend/packages/harness/deerflow/runtime/goal.py new file mode 100644 index 000000000..0b40fb498 --- /dev/null +++ b/backend/packages/harness/deerflow/runtime/goal.py @@ -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 = ( + "\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" + "" + ) + 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 diff --git a/backend/packages/harness/deerflow/runtime/runs/worker.py b/backend/packages/harness/deerflow/runtime/runs/worker.py index c8709f34d..7f09482c1 100644 --- a/backend/packages/harness/deerflow/runtime/runs/worker.py +++ b/backend/packages/harness/deerflow/runtime/runs/worker.py @@ -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( diff --git a/backend/packages/harness/deerflow/skills/slash.py b/backend/packages/harness/deerflow/skills/slash.py index 757acea25..f4dab54c3 100644 --- a/backend/packages/harness/deerflow/skills/slash.py +++ b/backend/packages/harness/deerflow/skills/slash.py @@ -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+|$)") diff --git a/backend/packages/harness/deerflow/tui/app.py b/backend/packages/harness/deerflow/tui/app.py index 75cb720d2..67051df62 100644 --- a/backend/packages/harness/deerflow/tui/app.py +++ b/backend/packages/harness/deerflow/tui/app.py @@ -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}")) diff --git a/backend/packages/harness/deerflow/tui/command_registry.py b/backend/packages/harness/deerflow/tui/command_registry.py index 016f4e77f..40597b16d 100644 --- a/backend/packages/harness/deerflow/tui/command_registry.py +++ b/backend/packages/harness/deerflow/tui/command_registry.py @@ -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"), diff --git a/backend/packages/harness/deerflow/utils/llm_text.py b/backend/packages/harness/deerflow/utils/llm_text.py new file mode 100644 index 000000000..bd792a3c0 --- /dev/null +++ b/backend/packages/harness/deerflow/utils/llm_text.py @@ -0,0 +1,49 @@ +"""Utilities for normalizing LLM response text before structured parsing.""" + +from __future__ import annotations + +import re + +# Matches a complete ... block (case-insensitive, spans newlines). +_THINK_BLOCK_RE = re.compile(r"]*>.*?", re.IGNORECASE | re.DOTALL) +# Matches a dangling, unclosed (model truncated at max_tokens mid-thought). +_OPEN_THINK_RE = re.compile(r"]*>", re.IGNORECASE) + + +def strip_think_blocks(text: str) -> str: + """Remove inline reasoning ```` 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) diff --git a/backend/tests/test_channels.py b/backend/tests/test_channels.py index bf7312d46..604195a45 100644 --- a/backend/tests/test_channels.py +++ b/backend/tests/test_channels.py @@ -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()) diff --git a/backend/tests/test_client.py b/backend/tests/test_client.py index 5b593bc86..4649ce224 100644 --- a/backend/tests/test_client.py +++ b/backend/tests/test_client.py @@ -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 diff --git a/backend/tests/test_gateway_lifespan_shutdown.py b/backend/tests/test_gateway_lifespan_shutdown.py index c1835bdf7..e3c5df583 100644 --- a/backend/tests/test_gateway_lifespan_shutdown.py +++ b/backend/tests/test_gateway_lifespan_shutdown.py @@ -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={}) diff --git a/backend/tests/test_goal_runtime.py b/backend/tests/test_goal_runtime.py new file mode 100644 index 000000000..61bd6b4f6 --- /dev/null +++ b/backend/tests/test_goal_runtime.py @@ -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('maybe {"satisfied": false}\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") diff --git a/backend/tests/test_goal_worker.py b/backend/tests/test_goal_worker.py new file mode 100644 index 000000000..cf35d036f --- /dev/null +++ b/backend/tests/test_goal_worker.py @@ -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" diff --git a/backend/tests/test_slash_skills.py b/backend/tests/test_slash_skills.py index e636ecb78..ec2e51968 100644 --- a/backend/tests/test_slash_skills.py +++ b/backend/tests/test_slash_skills.py @@ -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 diff --git a/backend/tests/test_thread_state_reducers.py b/backend/tests/test_thread_state_reducers.py index c3ad652f1..f89d66c93 100644 --- a/backend/tests/test_thread_state_reducers.py +++ b/backend/tests/test_thread_state_reducers.py @@ -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) diff --git a/backend/tests/test_threads_router.py b/backend/tests/test_threads_router.py index 74e4c7a50..b035c9bf7 100644 --- a/backend/tests/test_threads_router.py +++ b/backend/tests/test_threads_router.py @@ -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 diff --git a/backend/tests/test_tui_app.py b/backend/tests/test_tui_app.py index d9848540e..5ec02717f 100644 --- a/backend/tests/test_tui_app.py +++ b/backend/tests/test_tui_app.py @@ -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) diff --git a/backend/tests/test_tui_command_registry.py b/backend/tests/test_tui_command_registry.py index 151fd9176..575421092 100644 --- a/backend/tests/test_tui_command_registry.py +++ b/backend/tests/test_tui_command_registry.py @@ -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" diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index bd0a200a3..4150f3de0 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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 ` before normal chat submission, calling Gateway `GET/PUT/DELETE /api/threads/{thread_id}/goal`. Setting `/goal ` 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 diff --git a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx index 8389a96b3..09867e7a0 100644 --- a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx @@ -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 ( @@ -247,7 +253,7 @@ export default function AgentChatPage() { : "max-w-(--container-width-md)", )} > - {hasTodos && ( + {(hasGoal || hasTodos) && (
-
)} @@ -286,7 +295,9 @@ export default function AgentChatPage() { } context={settings.context} extraHeader={ - isWelcomeMode && ( + isWelcomeMode && + !hasGoal && + !hasTodos && ( ) } @@ -295,6 +306,7 @@ export default function AgentChatPage() { isUploading } onContextChange={(context) => setSettings("context", context)} + onGoalChange={setLocalGoal} onSubmit={handleSubmit} onStop={handleStop} /> diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index 4f056405b..6b93e268b 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -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 ( @@ -240,7 +246,7 @@ export default function ChatPage() { : "max-w-(--container-width-md)", )} > - {hasTodos && ( + {(hasGoal || hasTodos) && (
-
)} @@ -279,7 +288,9 @@ export default function ChatPage() { } context={settings.context} extraHeader={ - isWelcomeMode && + isWelcomeMode && + !hasGoal && + !hasTodos && } disabled={ isMock || @@ -289,6 +300,7 @@ export default function ChatPage() { onContextChange={(context) => setSettings("context", context) } + onGoalChange={setLocalGoal} onSubmit={handleSubmit} onStop={handleStop} /> diff --git a/frontend/src/components/workspace/goal-status-helpers.ts b/frontend/src/components/workspace/goal-status-helpers.ts new file mode 100644 index 000000000..ea84a438a --- /dev/null +++ b/frontend/src/components/workspace/goal-status-helpers.ts @@ -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, +): 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("|"); +} diff --git a/frontend/src/components/workspace/goal-status.tsx b/frontend/src/components/workspace/goal-status.tsx new file mode 100644 index 000000000..01dd17075 --- /dev/null +++ b/frontend/src/components/workspace/goal-status.tsx @@ -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 ( +
+ +
+ + {t.inputBox.goalLabel} + + {goal.objective} +
+ {continuation && ( + + + + {t.inputBox.goalContinuing + .replace("{count}", String(continuation.count)) + .replace("{max}", String(continuation.max))} + + + )} +
+ ); +} diff --git a/frontend/src/components/workspace/input-box-helpers.ts b/frontend/src/components/workspace/input-box-helpers.ts new file mode 100644 index 000000000..f89610eb6 --- /dev/null +++ b/frontend/src/components/workspace/input-box-helpers.ts @@ -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 { + 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}`; +} diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index 1d14aa259..583810a96 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -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; onStop?: () => void; }) { @@ -230,6 +191,7 @@ export function InputBox({ const { data: uploadLimits } = useUploadLimits(threadId); const promptRootRef = useRef(null); const textareaRef = useRef(null); + const goalRequestStateRef = useRef(createGoalRequestState()); const promptHistoryIndexRef = useRef(null); const promptHistoryDraftRef = useRef(""); @@ -251,6 +213,16 @@ export function InputBox({ const [pendingSuggestion, setPendingSuggestion] = useState( null, ); + const builtinSlashCommands = useMemo( + () => [ + { + 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 => { + 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 (