mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-22 22:18:15 +00:00
feat(runtime): implement goal continuations (#3858)
* implement goal continuations * fix(goal): address review findings for goal continuations - goal: key the no-progress breaker on a signature of the latest visible assistant evidence instead of the evaluator's volatile free-text, so it actually fires on stalled turns; thread the signature through every worker persist / no-progress call site - goal: align _stand_down_reason default caps with should_continue_goal (8 / 2) so the two gate functions agree on goals missing the fields - runtime: offload the synchronous checkpointer fallback via asyncio.to_thread (goal.py + worker.py) to keep blocking IO off the loop - frontend: i18n the GoalStatus "Goal" label (goalLabel in en/zh/types) - frontend: extract pure composer helpers into input-box-helpers.ts with unit tests (parseGoalCommand, readGoalResponseError, skill suggestions) - tests: cover the evidence-based no-progress and default-cap behavior - docs: align backend/AGENTS.md goal paragraph with actual behavior - e2e: prettier-format chat.spec.ts (fixes the lint-frontend CI failure) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(frontend): hide goal continuation counter until the agent continues The goal status bar rendered a raw "0/8" before any auto-continuation, which read as a mysterious score. Now the counter is hidden until continuation_count > 0, then shows "Continuing N/M" with a tooltip explaining the auto-continuation cap. - Extract getGoalContinuationDisplay into a pure helper (hides at 0) + unit tests - Add goalContinuing / goalContinuationTooltip i18n keys (en/zh/types) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(goal): address review findings for goal continuations Frontend correctness - Fix the optimistic /goal result permanently shadowing server goal state: the streamed continuation counter never surfaced for a goal set in-session. Extract a shared useActiveGoal hook (used by both chat pages) that reconciles the optimistic copy with server state via a goalReconciliationKey, de-duping the copy-pasted goal block across the two pages. - Stop /goal status|clear failures from escaping handleSubmit as unhandled rejections (handleGoalCommand now returns success; the run only starts when a goal was actually saved). - Use a function replacer for the goal-status toast so an objective containing $&/$1 isn't treated as a replacement pattern. Backend cleanliness / correctness - De-duplicate four byte-identical helpers (_call_checkpointer_method, _message_type, _additional_kwargs, _is_visible_message) by importing them from runtime.goal instead of re-defining them in the run worker. - Remove the dead `checkpoint_tuple.tasks` durability guard (CheckpointTuple has no tasks field) and document that pending_writes is the durability signal. - Decompose the 176-line _prepare_goal_continuation_input: extract _reread_goal_and_checkpoint and a _persist closure so the thread-unchanged guard and stand-down persistence aren't open-coded three times. Document the last-writer-wins write-window limitation as a follow-up. - Add a shared parse_goal_command helper and use it from the TUI and IM-channel /goal handlers (one place for the status/clear/set semantics). Tests - Restore the 11 command-registry tests dropped by the previous goal change (filter_commands ranking/description, build_registry builtins/skills, resolve cases) alongside the new goal tests. - Add coverage for the IM-channel _handle_goal_command, the TUI _handle_goal handler, parse_goal_command, and goalReconciliationKey. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix goal review feedback * fix goal continuation checkpoint races * prioritize goal commands while streaming Route composer submits through a shared helper so /goal commands can be handled before the streaming stop shortcut, while ordinary streaming submits still stop the active run. Testing: cd frontend && pnpm exec rstest run tests/unit/components/workspace/input-box-helpers.test.ts tests/unit/components/workspace/goal-status-helpers.test.ts; cd frontend && pnpm check * preserve goal status during clarification Keep omitted stream goal fields distinct from explicit null clears so clarification interrupts do not hide an active thread goal that is still present in the checkpoint. Testing: pnpm exec rstest run tests/unit/components/workspace/use-active-goal.test.ts tests/unit/components/workspace/input-box-helpers.test.ts tests/unit/components/workspace/goal-status-helpers.test.ts; pnpm check; git diff --check * style: format active goal hook Run Prettier on use-active-goal.ts to satisfy the frontend lint workflow formatting gate. Testing: pnpm format; pnpm exec rstest run tests/unit/components/workspace/use-active-goal.test.ts tests/unit/components/workspace/input-box-helpers.test.ts tests/unit/components/workspace/goal-status-helpers.test.ts; pnpm check; git diff --check * fix goal review followups --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
e3e5c73b03
commit
25ea6970a6
22
README.md
22
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 <completion condition>` to attach one active completion condition to the current thread. The goal is thread-scoped state, not a skill activation, so it stays active across turns until DeerFlow determines it has been satisfied or you clear it.
|
||||
|
||||
Supported commands:
|
||||
|
||||
```text
|
||||
/goal finish the implementation and make all tests pass
|
||||
/goal # show the active goal
|
||||
/goal clear # clear it
|
||||
```
|
||||
|
||||
After each Gateway-backed run, DeerFlow evaluates the visible conversation against the active goal with a non-thinking evaluator model. The evaluator must return a typed blocker (`missing_evidence`, `needs_user_input`, `run_failed`, `external_wait`, or `goal_not_met_yet`) plus visible evidence. DeerFlow only injects a hidden continuation when the latest assistant turn is durably checkpointed, the blocker is `goal_not_met_yet`, the thread did not change during evaluation, and the no-progress breaker has not fired. The safety cap defaults to 8 hidden continuations, and repeated identical non-progress evaluations stop after 2 attempts. `/goal clear` and any user-authored new input win over queued continuations. When the goal is satisfied, DeerFlow clears it automatically and publishes the updated thread state.
|
||||
|
||||
The Web UI shows the active goal above the composer. The same command is available from the TUI and supported IM channels. In the Web UI and supported IM channels, setting `/goal <completion condition>` also starts a run with the condition as the task; status and clear commands only manage goal state.
|
||||
|
||||
### Sub-Agents
|
||||
|
||||
Complex tasks rarely fit in a single pass. DeerFlow decomposes them.
|
||||
@ -742,6 +759,9 @@ models = client.list_models() # {"models": [...]}
|
||||
skills = client.list_skills() # {"skills": [...]}
|
||||
client.update_skill("web-search", enabled=True)
|
||||
client.upload_files("thread-1", ["./report.pdf"]) # {"success": True, "files": [...]}
|
||||
client.set_goal("thread-1", "finish the implementation and make all tests pass")
|
||||
client.get_goal("thread-1") # {"goal": {...}} or {"goal": None}
|
||||
client.clear_goal("thread-1")
|
||||
```
|
||||
|
||||
All dict-returning methods are validated against Gateway Pydantic response models in CI (`TestGatewayConformance`), ensuring the embedded client stays in sync with the HTTP API schemas. See `backend/packages/harness/deerflow/client.py` for full API documentation.
|
||||
@ -762,7 +782,7 @@ deerflow --print "summarize this repo" # headless one-shot answer to stdo
|
||||
deerflow --json "hello" # headless newline-delimited StreamEvents
|
||||
```
|
||||
|
||||
A keyboard-driven chat surface with a streaming transcript (Markdown-rendered answers), compact tool-activity cards, a `/` slash-command palette, `/model` and `/threads` pickers, input history, and `Esc` / `Ctrl+C` interrupt. Sessions opened in the TUI also appear in the Web UI sidebar — it writes the shared thread store under the local default user, so terminal and web stay in sync **without running the Gateway**.
|
||||
A keyboard-driven chat surface with a streaming transcript (Markdown-rendered answers), compact tool-activity cards, a `/` slash-command palette, `/goal` goal management, `/model` and `/threads` pickers, input history, and `Esc` / `Ctrl+C` interrupt. Sessions opened in the TUI also appear in the Web UI sidebar — it writes the shared thread store under the local default user, so terminal and web stay in sync **without running the Gateway**.
|
||||
|
||||
See [backend/docs/TUI.md](backend/docs/TUI.md) for the full guide.
|
||||
|
||||
|
||||
@ -190,8 +190,8 @@ from deerflow.config import get_app_config
|
||||
- System prompt generated by `apply_prompt_template()` with skills, memory, and subagent instructions
|
||||
|
||||
**ThreadState** (`packages/harness/deerflow/agents/thread_state.py`):
|
||||
- Extends `AgentState` with: `sandbox`, `thread_data`, `title`, `artifacts`, `todos`, `uploaded_files`, `viewed_images`, `promoted`, `delegations`, `skill_context`, `summary_text`
|
||||
- Uses custom reducers: `merge_artifacts` (deduplicate), `merge_viewed_images` (merge/clear), `merge_promoted` (catalog-hash-scoped deferred tool promotions), `merge_delegations` (append task delegation entries, same id latest wins, terminal status never downgraded, capped to the most recent entries), and `merge_skill_context` (dedupe active-skill references by path, keep the most recently read entries; entries store a name/path/description reference, not the SKILL.md body). `summary_text` is a LastValue channel updated by summarization and projected into model requests as durable context data instead of being stored as a `messages` item.
|
||||
- Extends `AgentState` with: `sandbox`, `thread_data`, `title`, `artifacts`, `todos`, `uploaded_files`, `viewed_images`, `goal`, `promoted`, `delegations`, `skill_context`, `summary_text`
|
||||
- Uses custom reducers: `merge_artifacts` (deduplicate), `merge_viewed_images` (merge/clear), `merge_goal` (preserve the active goal across ordinary state updates unless the goal writer replaces it), `merge_promoted` (catalog-hash-scoped deferred tool promotions), `merge_delegations` (append task delegation entries, same id latest wins, terminal status never downgraded, capped to the most recent entries), and `merge_skill_context` (dedupe active-skill references by path, keep the most recently read entries; entries store a name/path/description reference, not the SKILL.md body). `summary_text` is a LastValue channel updated by summarization and projected into model requests as durable context data instead of being stored as a `messages` item.
|
||||
|
||||
**Runtime Configuration** (via `config.configurable`):
|
||||
- `thinking_enabled` - Enable model's extended thinking
|
||||
@ -291,7 +291,7 @@ CORS is same-origin by default when requests enter through nginx on port 2026. S
|
||||
| **Skills** (`/api/skills`) | `GET /` - list skills; `GET /{name}` - details; `PUT /{name}` - update enabled; `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`) |
|
||||
| **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data |
|
||||
| **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete |
|
||||
| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
|
||||
| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; `GET /goal`, `PUT /goal`, `DELETE /goal` - read, set, and clear the active thread goal; unexpected failures are logged server-side and return a generic 500 detail |
|
||||
| **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types |
|
||||
| **Suggestions** (`/api/suggestions`) | `GET /config` - returns global suggestions config boolean; `POST /threads/{id}/suggestions` - generate follow-up questions; rich list/block model content is normalized and inline reasoning (`<think>...</think>`, including unclosed/truncated blocks from reasoning models like MiniMax-M3) is stripped before JSON parsing |
|
||||
| **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block; `POST /regenerate/prepare` - prepare clean input + checkpoint metadata for regenerating the latest assistant answer; `GET /` - list runs; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `GET /{rid}/messages` - paginated messages `{data, has_more}`; `GET /{rid}/events` - full event stream; `GET /../messages` - thread messages with feedback; `GET /../token-usage` - aggregate tokens |
|
||||
@ -305,6 +305,7 @@ CORS is same-origin by default when requests enter through nginx on port 2026. S
|
||||
- Store-only hydrated runs are readable history. If the current worker has no in-memory task/control state for that run, cancellation APIs can return 409 because this worker cannot stop the task.
|
||||
- `POST /wait` (both thread-scoped and `/api/runs/wait`) drains the stream bridge via `wait_for_run_completion()` instead of bare `await record.task`, so it honours the run's `on_disconnect` setting and cancels the background run on real client disconnect rather than returning a stale checkpoint (issue #3265).
|
||||
- Thread-scoped run creation accepts `checkpoint` / `checkpoint_id`; Gateway validates the checkpoint belongs to the request thread before writing `checkpoint_id` / `checkpoint_ns` into `config.configurable` for LangGraph branching.
|
||||
- Thread-scoped Gateway runs evaluate an active `ThreadState.goal` after the visible turn completes. `runtime/goal.py` asks a non-thinking evaluator model to judge only visible conversation evidence and return a typed blocker; the evaluator model is created once per run and reused across hidden continuation checks. Satisfied goals are cleared; every non-satisfied evaluation — continuable or stand-down — is persisted with `last_evaluation` (the blocker, reason, and evidence summary; outcomes that stop the loop additionally record a `stand_down_reason` for observability), but only `goal_not_met_yet` evaluations are streamed as hidden `HumanMessage` continuations, and only when a durable assistant end-of-turn checkpoint exists, the run has not been aborted, the thread did not change during evaluation, and the no-progress breaker has not fired. The continuation cap is 8 — a hard maximum in the `0`–`8` range; callers requesting more are clamped (`set_goal`/TUI) or rejected with 422 (`PUT /goal`). The no-progress breaker keys on the latest visible assistant evidence (not the evaluator's free-text reason, which an LLM rewords every turn), so two consecutive continuations that add no new visible assistant output stop the loop after 2 attempts. Model-response cleanup helpers such as think-block stripping and code-fence stripping live in `deerflow.utils.llm_text` so `runtime/goal.py` and Gateway suggestion parsing share the same JSON-prep behavior.
|
||||
|
||||
Proxied through nginx: `/api/langgraph/*` → Gateway LangGraph-compatible runtime, all other `/api/*` → Gateway REST APIs.
|
||||
|
||||
@ -388,7 +389,7 @@ Additional providers also live here (`brave`, `browserless`, `crawl4ai`, `ddg_se
|
||||
- **Format**: Directory with `SKILL.md` (YAML frontmatter: name, description, license, allowed-tools, required-secrets)
|
||||
- **Loading**: `load_skills()` recursively scans `skills/{public,custom}` for `SKILL.md`, parses metadata, and reads enabled state from extensions_config.json
|
||||
- **Injection**: Enabled skills listed in agent system prompt with container paths
|
||||
- **Slash activation**: `/skill-name task` loads that enabled skill's `SKILL.md` for the current model call only. The resolver rejects leading whitespace, missing separators, reserved channel commands (`/new`, `/help`, `/bootstrap`, `/status`, `/models`, `/memory`), disabled skills, and skills outside a custom agent's whitelist.
|
||||
- **Slash activation**: `/skill-name task` loads that enabled skill's `SKILL.md` for the current model call only. The resolver rejects leading whitespace, missing separators, reserved channel commands (`/new`, `/help`, `/bootstrap`, `/status`, `/models`, `/memory`, `/goal`), disabled skills, and skills outside a custom agent's whitelist.
|
||||
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
|
||||
|
||||
#### Request-Scoped Secrets (`required-secrets`)
|
||||
@ -427,7 +428,7 @@ Bridges external messaging platforms (Feishu, Slack, Telegram, Discord, DingTalk
|
||||
**Components**:
|
||||
- `message_bus.py` - Async pub/sub hub (`InboundMessage` → queue → dispatcher; `OutboundMessage` → callbacks → channels)
|
||||
- `store.py` - JSON-file persistence mapping `channel_name:chat_id[:topic_id]` → `thread_id` (keys are `channel:chat` for root conversations and `channel:chat:topic` for threaded conversations)
|
||||
- `manager.py` - Core dispatcher: creates threads via `client.threads.create()`, routes commands, keeps Slack/Discord on `client.runs.wait()`, and uses `client.runs.stream(["messages-tuple", "values"])` for Feishu/Telegram incremental outbound updates
|
||||
- `manager.py` - Core dispatcher: creates threads via `client.threads.create()`, routes commands including `/goal` (setting a goal persists it through Gateway and then routes the objective as a chat turn), keeps Slack/Discord on `client.runs.wait()`, and uses `client.runs.stream(["messages-tuple", "values"])` for Feishu/Telegram incremental outbound updates
|
||||
- `base.py` - Abstract `Channel` base class (start/stop/send lifecycle)
|
||||
- `service.py` - Manages lifecycle of all configured channels from `config.yaml`
|
||||
- `slack.py` / `feishu.py` / `telegram.py` / `discord.py` / `dingtalk.py` - Platform-specific implementations (`feishu.py` tracks the running card `message_id` in memory and patches the same card in place; `telegram.py` registers the "Working on it..." placeholder as the stream target and edits it in place via `editMessageText`; `dingtalk.py` optionally uses AI Card streaming for in-place updates when `card_template_id` is configured)
|
||||
@ -444,7 +445,7 @@ Bridges external messaging platforms (Feishu, Slack, Telegram, Discord, DingTalk
|
||||
7. Feishu channel sends one running reply card up front, then patches the same card for each outbound update (card JSON sets `config.update_multi=true` for Feishu's patch API requirement)
|
||||
8. Telegram streaming: the "Working on it..." placeholder message is registered as the stream target; non-final updates `editMessageText` it in place (channel-side throttle: 1s in private chats, 3s in groups due to Telegram's 20 msg/min group cap; 4096-char truncation; rate-limited updates dropped); the final update performs the last edit and splits >4096 texts into follow-up messages
|
||||
9. DingTalk AI Card mode (when `card_template_id` configured): `runs.stream()` → create card with initial text → stream updates via `PUT /v1.0/card/streaming` → finalize on `is_final=True`. Falls back to `sampleMarkdown` if card creation or streaming fails
|
||||
10. For commands (`/new`, `/status`, `/models`, `/memory`, `/help`): handle locally or query Gateway API
|
||||
10. For commands (`/new`, `/status`, `/models`, `/memory`, `/goal`, `/help`): handle locally or query Gateway API
|
||||
11. Outbound → channel callbacks → platform reply
|
||||
|
||||
**Owner-scoped file storage**: inbound files, uploads, and output artifacts are staged under the DeerFlow owner's bucket so they land where the agent run reads/writes (`users/{user_id}/threads/{thread_id}/user-data/{uploads,outputs}`). `ChannelManager._handle_chat` resolves the storage owner once via `_channel_storage_user_id(msg)` (sanitized owner id, falling back to `safe(msg.user_id)` for unbound auth-enabled channels — mirroring `_resolve_run_params`'s run identity; `None` only when no identity is available) and threads it as the `user_id=` kwarg through the file pipeline:
|
||||
@ -569,7 +570,7 @@ A terminal-native UI over the embedded harness, exposed as the `deerflow` consol
|
||||
- `view_state.py` — `ViewState` + `reduce(state, action)`, the testable heart. Rows: user / assistant / tool / system. Title captured from `values` events.
|
||||
- `runtime.py` — `translate(StreamEvent) -> [Action]` (pure) + `stream_actions()` which brackets a run with `RunStarted`/`RunEnded` and turns model errors into an `AssistantError` row.
|
||||
- `message_format.py` / `command_registry.py` / `input_history.py` / `render.py` / `theme.py` — pure helpers (tool summaries, slash registry + `resolve()`, ↑/↓ history, Rich renderers).
|
||||
- `app.py` — Textual `App`. Runs `DeerFlowClient.stream()` (sync) on a worker thread and marshals actions to the UI thread via `call_from_thread`. Slash palette + model/thread modal pickers; priority key bindings gated by `check_action` so they never steal keys from overlays or the composer.
|
||||
- `app.py` — Textual `App`. Runs `DeerFlowClient.stream()` (sync) on a worker thread and marshals actions to the UI thread via `call_from_thread`. Slash palette with `/goal` management + model/thread modal pickers; priority key bindings gated by `check_action` so they never steal keys from overlays or the composer.
|
||||
- `session.py` / `persistence.py` — builds the client + checkpointer and the `ThreadMetaWriter`.
|
||||
|
||||
**Web UI visibility**: the Web UI lists threads from the `threads_meta` SQL table (user-scoped), not the checkpointer. `persistence.py` writes a `threads_meta` row under the default user (`"default"`) into the same DB the Gateway reads — via the harness-only `deerflow.persistence.engine.init_engine_from_config()` — so TUI sessions appear in the Web UI sidebar **without** running the Gateway. Best-effort: a no-op on the `memory` backend. All DB work runs on one long-lived background event loop (a SQLAlchemy async engine is bound to its creating loop).
|
||||
@ -668,6 +669,7 @@ Both can be modified at runtime via Gateway API endpoints or `DeerFlowClient` me
|
||||
| Models | `list_models()`, `get_model(name)` | `{"models": [...]}`, `{name, display_name, ...}` |
|
||||
| MCP | `get_mcp_config()`, `update_mcp_config(servers)` | `{"mcp_servers": {...}}` |
|
||||
| Skills | `list_skills()`, `get_skill(name)`, `update_skill(name, enabled)`, `install_skill(path)` | `{"skills": [...]}` |
|
||||
| Goals | `get_goal(thread_id)`, `set_goal(thread_id, objective, max_continuations=8)`, `clear_goal(thread_id)` | `{"goal": {...}}` or `{"goal": None}` |
|
||||
| Memory | `get_memory()`, `reload_memory()`, `get_memory_config()`, `get_memory_status()` | dict |
|
||||
| Uploads | `upload_files(thread_id, files)`, `list_uploads(thread_id)`, `delete_upload(thread_id, filename)` | `{"success": true, "files": [...]}`, `{"files": [...], "count": N}` |
|
||||
| Artifacts | `get_artifact(thread_id, path)` → `(bytes, mime_type)` | tuple |
|
||||
|
||||
@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
KNOWN_CHANNEL_COMMANDS: frozenset[str] = frozenset(
|
||||
{
|
||||
"/bootstrap",
|
||||
"/goal",
|
||||
"/new",
|
||||
"/status",
|
||||
"/models",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
import deerflow.utils.llm_text as llm_text
|
||||
from app.gateway.authz import require_permission
|
||||
from app.gateway.deps import get_config
|
||||
from deerflow.config.app_config import AppConfig
|
||||
@ -38,39 +38,9 @@ class SuggestionsConfigResponse(BaseModel):
|
||||
enabled: bool = Field(..., description="Whether follow-up suggestions are enabled globally")
|
||||
|
||||
|
||||
# Matches a complete <think>...</think> block (case-insensitive, spans newlines).
|
||||
_THINK_BLOCK_RE = re.compile(r"<think\b[^>]*>.*?</think\s*>", re.IGNORECASE | re.DOTALL)
|
||||
# Matches a dangling, unclosed <think> (model truncated at max_tokens mid-thought).
|
||||
_OPEN_THINK_RE = re.compile(r"<think\b[^>]*>", re.IGNORECASE)
|
||||
|
||||
|
||||
def _strip_think_blocks(text: str) -> str:
|
||||
"""Remove reasoning-model ``<think>...</think>`` blocks from the response.
|
||||
|
||||
Reasoning models such as MiniMax-M3 inline their chain-of-thought into the
|
||||
message ``content`` wrapped in ``<think>...</think>`` (``reasoning_split``
|
||||
defaults to false), rather than exposing a separate ``reasoning_content``
|
||||
field. The thinking text frequently contains ``[`` / ``]`` characters, which
|
||||
corrupted the downstream ``find('[')`` / ``rfind(']')`` JSON extraction and
|
||||
produced empty suggestions. We strip the reasoning before parsing so only
|
||||
the actual answer remains.
|
||||
"""
|
||||
text = _THINK_BLOCK_RE.sub("", text)
|
||||
# Drop any unclosed <think> (and everything after it) left by truncation.
|
||||
open_match = _OPEN_THINK_RE.search(text)
|
||||
if open_match:
|
||||
text = text[: open_match.start()]
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _strip_markdown_code_fence(text: str) -> str:
|
||||
stripped = text.strip()
|
||||
if not stripped.startswith("```"):
|
||||
return stripped
|
||||
lines = stripped.splitlines()
|
||||
if len(lines) >= 3 and lines[0].startswith("```") and lines[-1].startswith("```"):
|
||||
return "\n".join(lines[1:-1]).strip()
|
||||
return stripped
|
||||
_extract_response_text = llm_text.extract_response_text
|
||||
_strip_markdown_code_fence = llm_text.strip_markdown_code_fence
|
||||
_strip_think_blocks = llm_text.strip_think_blocks
|
||||
|
||||
|
||||
def _parse_json_string_list(text: str) -> list[str] | None:
|
||||
@ -98,24 +68,6 @@ def _parse_json_string_list(text: str) -> list[str] | None:
|
||||
return out
|
||||
|
||||
|
||||
def _extract_response_text(content: object) -> str:
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for block in content:
|
||||
if isinstance(block, str):
|
||||
parts.append(block)
|
||||
elif isinstance(block, dict) and block.get("type") in {"text", "output_text"}:
|
||||
text = block.get("text")
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
return "\n".join(parts) if parts else ""
|
||||
if content is None:
|
||||
return ""
|
||||
return str(content)
|
||||
|
||||
|
||||
def _format_conversation(messages: list[SuggestionMessage]) -> str:
|
||||
parts: list[str] = []
|
||||
for m in messages:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -60,10 +60,12 @@ instead of hanging.
|
||||
|
||||
### Slash commands
|
||||
|
||||
`/help` `/new` `/threads` (`/switch`) `/model` `/skills` `/tools` `/mcp`
|
||||
`/memory` `/uploads` `/usage` `/config` `/quit`, plus `/<skill-name> task` to
|
||||
activate any enabled skill for the current turn (same semantics as elsewhere in
|
||||
DeerFlow). `/model` and `/threads` open modal pickers.
|
||||
`/help` `/new` `/goal` `/threads` (`/switch`) `/model` `/skills` `/tools`
|
||||
`/mcp` `/memory` `/uploads` `/usage` `/config` `/quit`, plus
|
||||
`/<skill-name> task` to activate any enabled skill for the current turn (same
|
||||
semantics as elsewhere in DeerFlow). `/model` and `/threads` open modal pickers.
|
||||
Use `/goal <condition>` to set the active thread goal, `/goal` to show it, and
|
||||
`/goal clear` to clear it.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
||||
31
backend/packages/harness/deerflow/agents/goal_state.py
Normal file
31
backend/packages/harness/deerflow/agents/goal_state.py
Normal file
@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal, NotRequired, TypedDict
|
||||
|
||||
GoalBlocker = Literal[
|
||||
"none",
|
||||
"missing_evidence",
|
||||
"needs_user_input",
|
||||
"run_failed",
|
||||
"external_wait",
|
||||
"goal_not_met_yet",
|
||||
]
|
||||
|
||||
|
||||
class GoalEvaluation(TypedDict):
|
||||
satisfied: bool
|
||||
blocker: GoalBlocker
|
||||
reason: str
|
||||
evidence_summary: NotRequired[str]
|
||||
|
||||
|
||||
class GoalState(TypedDict):
|
||||
objective: str
|
||||
status: Literal["active"]
|
||||
created_at: str
|
||||
updated_at: str
|
||||
continuation_count: int
|
||||
max_continuations: int
|
||||
no_progress_count: int
|
||||
max_no_progress_continuations: int
|
||||
last_evaluation: NotRequired[dict[str, Any]]
|
||||
@ -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]
|
||||
|
||||
@ -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 = []
|
||||
|
||||
522
backend/packages/harness/deerflow/runtime/goal.py
Normal file
522
backend/packages/harness/deerflow/runtime/goal.py
Normal file
@ -0,0 +1,522 @@
|
||||
"""Thread-scoped goal state and evaluator helpers.
|
||||
|
||||
This module implements the Claude Code-style goal loop primitives used by
|
||||
Gateway runs and thin API surfaces. It intentionally lives in ``deerflow`` so
|
||||
the harness can evaluate and continue runs without importing the FastAPI app.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import hashlib
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import weakref
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, Literal, NamedTuple
|
||||
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
from langgraph.checkpoint.base import empty_checkpoint, uuid6
|
||||
|
||||
import deerflow.utils.llm_text as llm_text
|
||||
from deerflow.agents.goal_state import GoalBlocker, GoalEvaluation, GoalState
|
||||
from deerflow.models import create_chat_model
|
||||
from deerflow.utils.messages import message_to_text
|
||||
from deerflow.utils.time import now_iso
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_MAX_GOAL_CONTINUATIONS = 8
|
||||
DEFAULT_MAX_NO_PROGRESS_CONTINUATIONS = 2
|
||||
MAX_GOAL_OBJECTIVE_CHARS = 4000
|
||||
MAX_GOAL_REASON_CHARS = 1000
|
||||
MAX_GOAL_EVIDENCE_CHARS = 1000
|
||||
MAX_GOAL_CONVERSATION_CHARS = 12000
|
||||
MAX_GOAL_CONVERSATION_MESSAGES = 30
|
||||
|
||||
GOAL_BLOCKERS: set[GoalBlocker] = {
|
||||
"none",
|
||||
"missing_evidence",
|
||||
"needs_user_input",
|
||||
"run_failed",
|
||||
"external_wait",
|
||||
"goal_not_met_yet",
|
||||
}
|
||||
CONTINUABLE_GOAL_BLOCKERS: set[GoalBlocker] = {"goal_not_met_yet"}
|
||||
|
||||
GOAL_CLEAR_ALIASES = frozenset({"clear", "reset", "off"})
|
||||
|
||||
_extract_response_text = llm_text.extract_response_text
|
||||
_strip_markdown_code_fence = llm_text.strip_markdown_code_fence
|
||||
_strip_think_blocks = llm_text.strip_think_blocks
|
||||
|
||||
_goal_locks_guard = threading.Lock()
|
||||
_goal_locks_by_loop: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, dict[str, asyncio.Lock]] = weakref.WeakKeyDictionary()
|
||||
|
||||
|
||||
class GoalWriteConflict(RuntimeError):
|
||||
"""Raised when a goal write is based on a stale checkpoint."""
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def goal_thread_lock(thread_id: str) -> AsyncIterator[None]:
|
||||
"""Serialize goal read-modify-write sequences within the current event loop."""
|
||||
loop = asyncio.get_running_loop()
|
||||
with _goal_locks_guard:
|
||||
locks = _goal_locks_by_loop.get(loop)
|
||||
if locks is None:
|
||||
locks = {}
|
||||
_goal_locks_by_loop[loop] = locks
|
||||
lock = locks.get(thread_id)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
locks[thread_id] = lock
|
||||
|
||||
async with lock:
|
||||
yield
|
||||
|
||||
|
||||
class GoalCommand(NamedTuple):
|
||||
"""Parsed intent of a ``/goal`` slash command argument string."""
|
||||
|
||||
kind: Literal["status", "clear", "set"]
|
||||
objective: str = ""
|
||||
|
||||
|
||||
def parse_goal_command(args: str) -> GoalCommand:
|
||||
"""Parse the argument string of a ``/goal`` command into an intent.
|
||||
|
||||
Shared by the TUI and IM-channel surfaces so the three-way semantics stay in
|
||||
one place: empty shows the active goal, ``clear``/``reset``/``off`` clears it,
|
||||
and anything else sets the goal to that (trimmed) objective. The frontend
|
||||
keeps a parallel TypeScript copy in ``input-box-helpers.ts``.
|
||||
"""
|
||||
stripped = args.strip()
|
||||
if not stripped:
|
||||
return GoalCommand("status")
|
||||
if stripped.lower() in GOAL_CLEAR_ALIASES:
|
||||
return GoalCommand("clear")
|
||||
return GoalCommand("set", stripped)
|
||||
|
||||
|
||||
def normalize_goal_objective(objective: str) -> str:
|
||||
"""Normalize and validate user-provided goal text."""
|
||||
normalized = " ".join(objective.strip().split())
|
||||
if not normalized:
|
||||
raise ValueError("Goal objective must not be empty.")
|
||||
if len(normalized) > MAX_GOAL_OBJECTIVE_CHARS:
|
||||
raise ValueError(f"Goal objective must be at most {MAX_GOAL_OBJECTIVE_CHARS} characters.")
|
||||
return normalized
|
||||
|
||||
|
||||
def build_goal_state(
|
||||
objective: str,
|
||||
*,
|
||||
max_continuations: int = DEFAULT_MAX_GOAL_CONTINUATIONS,
|
||||
max_no_progress_continuations: int = DEFAULT_MAX_NO_PROGRESS_CONTINUATIONS,
|
||||
now: str | None = None,
|
||||
) -> GoalState:
|
||||
"""Create a fresh active goal state for a thread."""
|
||||
objective = normalize_goal_objective(objective)
|
||||
capped_max = max(0, min(int(max_continuations), DEFAULT_MAX_GOAL_CONTINUATIONS))
|
||||
timestamp = now or now_iso()
|
||||
return GoalState(
|
||||
objective=objective,
|
||||
status="active",
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp,
|
||||
continuation_count=0,
|
||||
max_continuations=capped_max,
|
||||
no_progress_count=0,
|
||||
max_no_progress_continuations=max(0, int(max_no_progress_continuations)),
|
||||
)
|
||||
|
||||
|
||||
def parse_goal_evaluation_response(text: str) -> GoalEvaluation:
|
||||
"""Parse the evaluator's JSON object response."""
|
||||
candidate = _strip_markdown_code_fence(_strip_think_blocks(text))
|
||||
start = candidate.find("{")
|
||||
end = candidate.rfind("}")
|
||||
if start == -1 or end == -1 or end <= start:
|
||||
raise ValueError("Goal evaluator response did not contain a JSON object.")
|
||||
try:
|
||||
payload = json.loads(candidate[start : end + 1])
|
||||
except Exception as exc:
|
||||
raise ValueError("Goal evaluator response was not valid JSON.") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Goal evaluator JSON must be an object.")
|
||||
satisfied = payload.get("satisfied")
|
||||
if not isinstance(satisfied, bool):
|
||||
raise ValueError("Goal evaluator JSON must include boolean 'satisfied'.")
|
||||
reason = _normalize_evaluation_text(payload.get("reason"), max_chars=MAX_GOAL_REASON_CHARS)
|
||||
evidence_summary = _normalize_evaluation_text(payload.get("evidence_summary"), max_chars=MAX_GOAL_EVIDENCE_CHARS)
|
||||
blocker = _normalize_goal_blocker(payload.get("blocker"), satisfied=satisfied)
|
||||
return GoalEvaluation(
|
||||
satisfied=satisfied,
|
||||
blocker=blocker,
|
||||
reason=reason,
|
||||
evidence_summary=evidence_summary,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_evaluation_text(value: object, *, max_chars: int) -> str:
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
return " ".join(value.strip().split())[:max_chars]
|
||||
|
||||
|
||||
def _normalize_goal_blocker(value: object, *, satisfied: bool) -> GoalBlocker:
|
||||
if satisfied:
|
||||
return "none"
|
||||
if isinstance(value, str) and value in GOAL_BLOCKERS and value != "none":
|
||||
return value
|
||||
return "missing_evidence"
|
||||
|
||||
|
||||
def _message_type(message: Any) -> str | None:
|
||||
value = getattr(message, "type", None)
|
||||
if value is None and isinstance(message, dict):
|
||||
value = message.get("type") or message.get("role")
|
||||
if value == "assistant":
|
||||
return "ai"
|
||||
if value == "user":
|
||||
return "human"
|
||||
return str(value) if value else None
|
||||
|
||||
|
||||
def _additional_kwargs(message: Any) -> dict[str, Any]:
|
||||
value = getattr(message, "additional_kwargs", None)
|
||||
if value is None and isinstance(message, dict):
|
||||
value = message.get("additional_kwargs")
|
||||
return dict(value) if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _is_visible_message(message: Any) -> bool:
|
||||
if _additional_kwargs(message).get("hide_from_ui") is True:
|
||||
return False
|
||||
return _message_type(message) in {"human", "ai"}
|
||||
|
||||
|
||||
def has_visible_assistant_evidence(messages: list[Any]) -> bool:
|
||||
"""Return true when the evaluator can inspect at least one visible AI reply."""
|
||||
return any(_is_visible_message(message) and _message_type(message) == "ai" and bool(message_to_text(message).strip()) for message in messages)
|
||||
|
||||
|
||||
def visible_conversation_signature(messages: list[Any]) -> str:
|
||||
"""Return a stable lightweight signature for the visible evaluator evidence."""
|
||||
visible = []
|
||||
for message in messages:
|
||||
if not _is_visible_message(message):
|
||||
continue
|
||||
visible.append(
|
||||
{
|
||||
"role": _message_type(message),
|
||||
"text": message_to_text(message).strip(),
|
||||
}
|
||||
)
|
||||
return json.dumps(visible[-MAX_GOAL_CONVERSATION_MESSAGES:], ensure_ascii=False, sort_keys=True)
|
||||
|
||||
|
||||
def format_visible_conversation(messages: list[Any]) -> str:
|
||||
"""Return the user-visible conversation evidence for goal evaluation."""
|
||||
lines: list[str] = []
|
||||
visible = [message for message in messages if _is_visible_message(message)]
|
||||
for message in visible[-MAX_GOAL_CONVERSATION_MESSAGES:]:
|
||||
text = message_to_text(message).strip()
|
||||
if not text:
|
||||
continue
|
||||
role = "User" if _message_type(message) == "human" else "Assistant"
|
||||
lines.append(f"{role}: {text}")
|
||||
conversation = "\n\n".join(lines)
|
||||
if len(conversation) > MAX_GOAL_CONVERSATION_CHARS:
|
||||
conversation = conversation[-MAX_GOAL_CONVERSATION_CHARS:]
|
||||
return conversation
|
||||
|
||||
|
||||
def create_goal_evaluator_model(
|
||||
*,
|
||||
model_name: str | None = None,
|
||||
app_config: Any | None = None,
|
||||
) -> Any:
|
||||
"""Create the non-thinking chat model used by the goal evaluator."""
|
||||
return create_chat_model(
|
||||
name=model_name,
|
||||
thinking_enabled=False,
|
||||
app_config=app_config,
|
||||
attach_tracing=False,
|
||||
)
|
||||
|
||||
|
||||
async def evaluate_goal_completion(
|
||||
goal: GoalState,
|
||||
messages: list[Any],
|
||||
*,
|
||||
model: Any | None = None,
|
||||
model_name: str | None = None,
|
||||
app_config: Any | None = None,
|
||||
) -> GoalEvaluation:
|
||||
"""Ask a small non-thinking model whether the active goal is satisfied."""
|
||||
conversation = format_visible_conversation(messages)
|
||||
if not conversation or not has_visible_assistant_evidence(messages):
|
||||
return GoalEvaluation(
|
||||
satisfied=False,
|
||||
blocker="missing_evidence",
|
||||
reason="No visible assistant evidence is available yet.",
|
||||
evidence_summary="",
|
||||
)
|
||||
|
||||
system_instruction = (
|
||||
"You are a strict completion evaluator for an AI coding assistant.\n"
|
||||
"Decide whether the active goal is fully satisfied using ONLY the visible conversation evidence.\n"
|
||||
"Do not assume files, commands, tests, or external state changed unless the conversation explicitly shows it.\n"
|
||||
"If the visible evidence is too weak to prove progress, fail closed with blocker missing_evidence.\n"
|
||||
"Use blocker needs_user_input when the assistant is waiting on the user, run_failed when the turn failed, "
|
||||
"external_wait when work is waiting on an outside system, goal_not_met_yet when useful autonomous work can continue, "
|
||||
"and none only when satisfied is true.\n"
|
||||
'Output exactly one JSON object: {"satisfied": boolean, "blocker": string, "reason": string, "evidence_summary": string}.'
|
||||
)
|
||||
user_content = f"Active goal:\n{goal['objective']}\n\nVisible conversation evidence:\n{conversation}\n\nIs the active goal fully satisfied?"
|
||||
|
||||
if model is None:
|
||||
model = create_goal_evaluator_model(model_name=model_name, app_config=app_config)
|
||||
response = await model.ainvoke(
|
||||
[SystemMessage(content=system_instruction), HumanMessage(content=user_content)],
|
||||
config={"run_name": "goal_evaluator"},
|
||||
)
|
||||
return parse_goal_evaluation_response(_extract_response_text(response.content))
|
||||
|
||||
|
||||
def should_continue_goal(goal: GoalState, evaluation: GoalEvaluation, *, no_progress_count: int | None = None) -> bool:
|
||||
"""Return whether another hidden continuation turn should run."""
|
||||
if evaluation["satisfied"]:
|
||||
return False
|
||||
if evaluation["blocker"] not in CONTINUABLE_GOAL_BLOCKERS:
|
||||
return False
|
||||
if int(goal.get("continuation_count", 0)) >= int(goal.get("max_continuations", DEFAULT_MAX_GOAL_CONTINUATIONS)):
|
||||
return False
|
||||
current_no_progress = int(goal.get("no_progress_count", 0) if no_progress_count is None else no_progress_count)
|
||||
max_no_progress = int(goal.get("max_no_progress_continuations", DEFAULT_MAX_NO_PROGRESS_CONTINUATIONS))
|
||||
return current_no_progress < max_no_progress
|
||||
|
||||
|
||||
def latest_visible_assistant_signature(messages: list[Any]) -> str:
|
||||
"""Return a stable signature of the latest visible assistant evidence.
|
||||
|
||||
The "no progress" breaker keys on what the agent actually produced — the
|
||||
text of the most recent user-visible assistant message — not on the
|
||||
evaluator's free-text ``reason``/``evidence_summary`` (which an LLM rewords
|
||||
on every turn, so it almost never repeats byte-for-byte). When a
|
||||
continuation adds no new visible assistant output, the signature is
|
||||
unchanged and the breaker can recognise the stalled turn.
|
||||
"""
|
||||
for message in reversed(messages):
|
||||
if not _is_visible_message(message) or _message_type(message) != "ai":
|
||||
continue
|
||||
text = message_to_text(message).strip()
|
||||
if text:
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
return ""
|
||||
|
||||
|
||||
def compute_goal_progress_key(evaluation: GoalEvaluation, *, evidence_signature: str = "") -> str:
|
||||
"""Return a stable key used to detect repeated non-progress evaluations.
|
||||
|
||||
Keyed on the typed ``blocker`` plus a signature of the visible assistant
|
||||
evidence, so a stalled goal is detected even when the evaluator rewords its
|
||||
free-text ``reason``/``evidence_summary``.
|
||||
"""
|
||||
return json.dumps(
|
||||
{
|
||||
"satisfied": evaluation["satisfied"],
|
||||
"blocker": evaluation["blocker"],
|
||||
"evidence_signature": evidence_signature,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
)
|
||||
|
||||
|
||||
def compute_no_progress_count(goal: GoalState, evaluation: GoalEvaluation, *, evidence_signature: str = "") -> int:
|
||||
"""Increment repeated-progress count when visible evidence has not advanced."""
|
||||
if evaluation["satisfied"]:
|
||||
return 0
|
||||
progress_key = compute_goal_progress_key(evaluation, evidence_signature=evidence_signature)
|
||||
previous = goal.get("last_evaluation", {})
|
||||
if isinstance(previous, dict) and previous.get("progress_key") == progress_key:
|
||||
return int(goal.get("no_progress_count", 0)) + 1
|
||||
return 0
|
||||
|
||||
|
||||
def make_goal_continuation_message(goal: GoalState, evaluation: GoalEvaluation) -> HumanMessage:
|
||||
"""Build the hidden user message that asks the agent to keep working."""
|
||||
content = (
|
||||
"<goal_continuation>\n"
|
||||
f"Active goal: {goal['objective']}\n"
|
||||
f"Evaluator result: not satisfied. Blocker: {evaluation['blocker']}. Reason: {evaluation['reason'] or 'No reason provided.'}\n"
|
||||
f"Visible evidence: {evaluation.get('evidence_summary') or 'No evidence summary provided.'}\n"
|
||||
"Continue working toward the active goal. Use the available tools and conversation context. "
|
||||
"Do not ask the user to continue unless you are genuinely blocked.\n"
|
||||
"</goal_continuation>"
|
||||
)
|
||||
return HumanMessage(
|
||||
content=content,
|
||||
additional_kwargs={
|
||||
"hide_from_ui": True,
|
||||
"deerflow_goal_continuation": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _call_checkpointer_method(checkpointer: Any, async_name: str, sync_name: str, *args: Any, **kwargs: Any) -> Any:
|
||||
async_method = getattr(checkpointer, async_name, None)
|
||||
if async_method is not None:
|
||||
result = async_method(*args, **kwargs)
|
||||
return await result if inspect.isawaitable(result) else result
|
||||
sync_method = getattr(checkpointer, sync_name, None)
|
||||
if sync_method is None:
|
||||
raise AttributeError(f"Missing checkpointer method: {async_name}/{sync_name}")
|
||||
# Offload the synchronous checkpointer call so its blocking IO never runs on
|
||||
# the event loop (backend/AGENTS.md blocking-IO gate).
|
||||
result = await asyncio.to_thread(sync_method, *args, **kwargs)
|
||||
return await result if inspect.isawaitable(result) else result
|
||||
|
||||
|
||||
async def ensure_thread_checkpoint(checkpointer: Any, thread_id: str) -> None:
|
||||
"""Create an empty root checkpoint for *thread_id* when none exists."""
|
||||
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||||
checkpoint_tuple = await _call_checkpointer_method(checkpointer, "aget_tuple", "get_tuple", config)
|
||||
if checkpoint_tuple is not None:
|
||||
return
|
||||
metadata = {
|
||||
"step": -1,
|
||||
"source": "input",
|
||||
"writes": None,
|
||||
"parents": {},
|
||||
"created_at": now_iso(),
|
||||
}
|
||||
await _call_checkpointer_method(checkpointer, "aput", "put", config, empty_checkpoint(), metadata, {})
|
||||
|
||||
|
||||
def _checkpoint_id_from_tuple(checkpoint_tuple: Any) -> str | None:
|
||||
config = getattr(checkpoint_tuple, "config", {}) or {}
|
||||
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
|
||||
checkpoint_id = configurable.get("checkpoint_id") if isinstance(configurable, dict) else None
|
||||
if isinstance(checkpoint_id, str):
|
||||
return checkpoint_id
|
||||
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
|
||||
if isinstance(checkpoint, dict) and isinstance(checkpoint.get("id"), str):
|
||||
return checkpoint["id"]
|
||||
return None
|
||||
|
||||
|
||||
async def read_thread_goal(checkpointer: Any, thread_id: str) -> GoalState | None:
|
||||
"""Read the latest thread goal from checkpoint state."""
|
||||
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||||
checkpoint_tuple = await _call_checkpointer_method(checkpointer, "aget_tuple", "get_tuple", config)
|
||||
if checkpoint_tuple is None:
|
||||
return None
|
||||
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
|
||||
channel_values = checkpoint.get("channel_values", {}) if isinstance(checkpoint, dict) else {}
|
||||
raw_goal = channel_values.get("goal") if isinstance(channel_values, dict) else None
|
||||
return copy.deepcopy(raw_goal) if isinstance(raw_goal, dict) else None
|
||||
|
||||
|
||||
async def write_thread_goal(
|
||||
checkpointer: Any,
|
||||
thread_id: str,
|
||||
goal: GoalState | None,
|
||||
*,
|
||||
as_node: str = "goal",
|
||||
create_if_missing: bool = False,
|
||||
expected_checkpoint_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Write a new checkpoint with the thread goal set or cleared.
|
||||
|
||||
Returns the updated channel values.
|
||||
"""
|
||||
if create_if_missing:
|
||||
await ensure_thread_checkpoint(checkpointer, thread_id)
|
||||
|
||||
read_config: dict[str, Any] = {
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": "",
|
||||
}
|
||||
}
|
||||
checkpoint_tuple = await _call_checkpointer_method(checkpointer, "aget_tuple", "get_tuple", read_config)
|
||||
if checkpoint_tuple is None:
|
||||
raise LookupError(f"Thread {thread_id} checkpoint not found")
|
||||
if expected_checkpoint_id is not None and _checkpoint_id_from_tuple(checkpoint_tuple) != expected_checkpoint_id:
|
||||
raise GoalWriteConflict(f"Thread {thread_id} goal checkpoint changed while preparing write")
|
||||
|
||||
checkpoint: dict[str, Any] = dict(getattr(checkpoint_tuple, "checkpoint", {}) or {})
|
||||
metadata: dict[str, Any] = dict(getattr(checkpoint_tuple, "metadata", {}) or {})
|
||||
channel_values: dict[str, Any] = dict(checkpoint.get("channel_values", {}) or {})
|
||||
|
||||
if goal is None:
|
||||
channel_values.pop("goal", None)
|
||||
else:
|
||||
channel_values["goal"] = copy.deepcopy(goal)
|
||||
|
||||
channel_versions = dict(checkpoint.get("channel_versions", {}) or {})
|
||||
current_version = channel_versions.get("goal")
|
||||
get_next_version = getattr(checkpointer, "get_next_version", None)
|
||||
if callable(get_next_version):
|
||||
next_version = get_next_version(current_version, None)
|
||||
elif isinstance(current_version, int):
|
||||
next_version = current_version + 1
|
||||
else:
|
||||
next_version = 1
|
||||
channel_versions["goal"] = next_version
|
||||
|
||||
checkpoint["channel_values"] = channel_values
|
||||
checkpoint["channel_versions"] = channel_versions
|
||||
checkpoint["id"] = str(uuid6())
|
||||
metadata["updated_at"] = now_iso()
|
||||
metadata["source"] = "update"
|
||||
metadata["step"] = metadata.get("step", 0) + 1
|
||||
metadata["writes"] = {as_node: {"goal": goal}}
|
||||
|
||||
write_config = {
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": "",
|
||||
}
|
||||
}
|
||||
await _call_checkpointer_method(checkpointer, "aput", "put", write_config, checkpoint, metadata, {"goal": next_version})
|
||||
return channel_values
|
||||
|
||||
|
||||
def attach_goal_evaluation(
|
||||
goal: GoalState,
|
||||
evaluation: GoalEvaluation,
|
||||
*,
|
||||
run_id: str,
|
||||
continuation_count: int | None = None,
|
||||
no_progress_count: int | None = None,
|
||||
stand_down_reason: str | None = None,
|
||||
evidence_signature: str = "",
|
||||
) -> GoalState:
|
||||
"""Return a goal copy with the latest evaluator result attached."""
|
||||
next_goal = copy.deepcopy(goal)
|
||||
if continuation_count is not None:
|
||||
next_goal["continuation_count"] = continuation_count
|
||||
if no_progress_count is not None:
|
||||
next_goal["no_progress_count"] = no_progress_count
|
||||
next_goal["updated_at"] = now_iso()
|
||||
next_goal["last_evaluation"] = {
|
||||
"satisfied": evaluation["satisfied"],
|
||||
"blocker": evaluation["blocker"],
|
||||
"reason": evaluation["reason"],
|
||||
"evidence_summary": evaluation.get("evidence_summary", ""),
|
||||
"run_id": run_id,
|
||||
"evaluated_at": next_goal["updated_at"],
|
||||
"progress_key": compute_goal_progress_key(evaluation, evidence_signature=evidence_signature),
|
||||
}
|
||||
if stand_down_reason:
|
||||
next_goal["last_evaluation"]["stand_down_reason"] = stand_down_reason
|
||||
return next_goal
|
||||
@ -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(
|
||||
|
||||
@ -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+|$)")
|
||||
|
||||
|
||||
|
||||
@ -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}"))
|
||||
|
||||
@ -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"),
|
||||
|
||||
49
backend/packages/harness/deerflow/utils/llm_text.py
Normal file
49
backend/packages/harness/deerflow/utils/llm_text.py
Normal file
@ -0,0 +1,49 @@
|
||||
"""Utilities for normalizing LLM response text before structured parsing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# Matches a complete <think>...</think> block (case-insensitive, spans newlines).
|
||||
_THINK_BLOCK_RE = re.compile(r"<think\b[^>]*>.*?</think\s*>", re.IGNORECASE | re.DOTALL)
|
||||
# Matches a dangling, unclosed <think> (model truncated at max_tokens mid-thought).
|
||||
_OPEN_THINK_RE = re.compile(r"<think\b[^>]*>", re.IGNORECASE)
|
||||
|
||||
|
||||
def strip_think_blocks(text: str) -> str:
|
||||
"""Remove inline reasoning ``<think>`` blocks from a model response."""
|
||||
text = _THINK_BLOCK_RE.sub("", text)
|
||||
open_match = _OPEN_THINK_RE.search(text)
|
||||
if open_match:
|
||||
text = text[: open_match.start()]
|
||||
return text.strip()
|
||||
|
||||
|
||||
def strip_markdown_code_fence(text: str) -> str:
|
||||
"""Remove a single wrapping markdown code fence when present."""
|
||||
stripped = text.strip()
|
||||
if not stripped.startswith("```"):
|
||||
return stripped
|
||||
lines = stripped.splitlines()
|
||||
if len(lines) >= 3 and lines[0].startswith("```") and lines[-1].startswith("```"):
|
||||
return "\n".join(lines[1:-1]).strip()
|
||||
return stripped
|
||||
|
||||
|
||||
def extract_response_text(content: object) -> str:
|
||||
"""Extract textual content from common chat-model response content shapes."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for block in content:
|
||||
if isinstance(block, str):
|
||||
parts.append(block)
|
||||
elif isinstance(block, dict) and block.get("type") in {"text", "output_text"}:
|
||||
text = block.get("text")
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
return "\n".join(parts)
|
||||
if content is None:
|
||||
return ""
|
||||
return str(content)
|
||||
@ -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())
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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={})
|
||||
|
||||
|
||||
236
backend/tests/test_goal_runtime.py
Normal file
236
backend/tests/test_goal_runtime.py
Normal file
@ -0,0 +1,236 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
||||
|
||||
from deerflow.runtime import goal
|
||||
|
||||
|
||||
def test_build_goal_state_defaults_to_claude_stop_hook_cap():
|
||||
state = goal.build_goal_state("Finish the tests")
|
||||
|
||||
assert state["objective"] == "Finish the tests"
|
||||
assert state["status"] == "active"
|
||||
assert state["continuation_count"] == 0
|
||||
assert state["max_continuations"] == 8
|
||||
assert state["no_progress_count"] == 0
|
||||
assert state["max_no_progress_continuations"] == 2
|
||||
assert state["created_at"]
|
||||
|
||||
|
||||
def test_parse_goal_evaluation_extracts_json_object_from_fenced_response():
|
||||
parsed = goal.parse_goal_evaluation_response('```json\n{"satisfied": true, "reason": "All requested tests pass.", "evidence_summary": "pytest passed"}\n```')
|
||||
|
||||
assert parsed["satisfied"] is True
|
||||
assert parsed["blocker"] == "none"
|
||||
assert parsed["reason"] == "All requested tests pass."
|
||||
assert parsed["evidence_summary"] == "pytest passed"
|
||||
|
||||
|
||||
def test_parse_goal_evaluation_strips_think_blocks():
|
||||
parsed = goal.parse_goal_evaluation_response('<think>maybe {"satisfied": false}</think>\n{"satisfied": false, "reason": "Missing verification."}')
|
||||
|
||||
assert parsed["satisfied"] is False
|
||||
assert parsed["blocker"] == "missing_evidence"
|
||||
assert parsed["reason"] == "Missing verification."
|
||||
|
||||
|
||||
def test_parse_goal_evaluation_preserves_typed_blocker():
|
||||
parsed = goal.parse_goal_evaluation_response('{"satisfied": false, "blocker": "needs_user_input", "reason": "The user must choose a deployment target."}')
|
||||
|
||||
assert parsed["satisfied"] is False
|
||||
assert parsed["blocker"] == "needs_user_input"
|
||||
|
||||
|
||||
def test_format_visible_conversation_excludes_hidden_and_system_messages():
|
||||
messages = [
|
||||
SystemMessage(content="internal"),
|
||||
HumanMessage(content="visible user"),
|
||||
HumanMessage(content="hidden control", additional_kwargs={"hide_from_ui": True}),
|
||||
AIMessage(content="visible assistant"),
|
||||
]
|
||||
|
||||
formatted = goal.format_visible_conversation(messages)
|
||||
|
||||
assert "visible user" in formatted
|
||||
assert "visible assistant" in formatted
|
||||
assert "hidden control" not in formatted
|
||||
assert "internal" not in formatted
|
||||
|
||||
|
||||
def test_should_continue_goal_respects_completion_and_cap():
|
||||
active = goal.build_goal_state("Finish", max_continuations=2)
|
||||
unmet = goal.GoalEvaluation(satisfied=False, blocker="goal_not_met_yet", reason="not yet")
|
||||
met = goal.GoalEvaluation(satisfied=True, blocker="none", reason="done")
|
||||
missing_evidence = goal.GoalEvaluation(satisfied=False, blocker="missing_evidence", reason="weak transcript")
|
||||
|
||||
assert goal.should_continue_goal(active, unmet) is True
|
||||
assert goal.should_continue_goal({**active, "continuation_count": 2}, unmet) is False
|
||||
assert goal.should_continue_goal(active, met) is False
|
||||
assert goal.should_continue_goal(active, missing_evidence) is False
|
||||
|
||||
|
||||
def test_should_continue_goal_respects_no_progress_cap():
|
||||
active = goal.build_goal_state("Finish")
|
||||
unmet = goal.GoalEvaluation(satisfied=False, blocker="goal_not_met_yet", reason="same evidence")
|
||||
|
||||
assert goal.should_continue_goal(active, unmet, no_progress_count=0) is True
|
||||
assert goal.should_continue_goal(active, unmet, no_progress_count=active["max_no_progress_continuations"]) is False
|
||||
|
||||
|
||||
def test_make_goal_continuation_message_is_hidden_from_ui():
|
||||
state = goal.build_goal_state("Finish the implementation")
|
||||
evaluation = goal.GoalEvaluation(satisfied=False, blocker="goal_not_met_yet", reason="Tests have not run")
|
||||
|
||||
message = goal.make_goal_continuation_message(state, evaluation)
|
||||
|
||||
assert message.additional_kwargs["hide_from_ui"] is True
|
||||
assert "Finish the implementation" in message.content
|
||||
assert "Tests have not run" in message.content
|
||||
|
||||
|
||||
def test_evaluate_goal_completion_uses_non_thinking_model(monkeypatch):
|
||||
fake_model = MagicMock()
|
||||
fake_model.ainvoke = AsyncMock(return_value=SimpleNamespace(content='{"satisfied": true, "reason": "Done", "evidence_summary": "Done"}'))
|
||||
captured = {}
|
||||
|
||||
def fake_create_chat_model(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return fake_model
|
||||
|
||||
monkeypatch.setattr(goal, "create_chat_model", fake_create_chat_model)
|
||||
state = goal.build_goal_state("Finish")
|
||||
|
||||
result = asyncio.run(
|
||||
goal.evaluate_goal_completion(
|
||||
state,
|
||||
[
|
||||
HumanMessage(content="Please finish this."),
|
||||
AIMessage(content="Done."),
|
||||
],
|
||||
app_config=object(),
|
||||
)
|
||||
)
|
||||
|
||||
assert result["satisfied"] is True
|
||||
assert result["blocker"] == "none"
|
||||
assert captured["thinking_enabled"] is False
|
||||
assert captured["attach_tracing"] is False
|
||||
fake_model.ainvoke.assert_awaited_once()
|
||||
assert fake_model.ainvoke.await_args.kwargs["config"] == {"run_name": "goal_evaluator"}
|
||||
|
||||
|
||||
def test_evaluate_goal_completion_uses_injected_model(monkeypatch):
|
||||
fake_model = MagicMock()
|
||||
fake_model.ainvoke = AsyncMock(return_value=SimpleNamespace(content='{"satisfied": true, "reason": "Done", "evidence_summary": "Done"}'))
|
||||
create_chat_model = MagicMock()
|
||||
monkeypatch.setattr(goal, "create_chat_model", create_chat_model)
|
||||
state = goal.build_goal_state("Finish")
|
||||
|
||||
result = asyncio.run(
|
||||
goal.evaluate_goal_completion(
|
||||
state,
|
||||
[
|
||||
HumanMessage(content="Please finish this."),
|
||||
AIMessage(content="Done."),
|
||||
],
|
||||
model=fake_model,
|
||||
app_config=object(),
|
||||
)
|
||||
)
|
||||
|
||||
assert result["satisfied"] is True
|
||||
create_chat_model.assert_not_called()
|
||||
fake_model.ainvoke.assert_awaited_once()
|
||||
|
||||
|
||||
def test_evaluate_goal_completion_fails_closed_without_assistant_evidence(monkeypatch):
|
||||
fake_model = MagicMock()
|
||||
fake_model.ainvoke = AsyncMock()
|
||||
monkeypatch.setattr(goal, "create_chat_model", lambda **_kwargs: fake_model)
|
||||
state = goal.build_goal_state("Finish")
|
||||
|
||||
result = asyncio.run(goal.evaluate_goal_completion(state, [HumanMessage(content="please do it")], app_config=object()))
|
||||
|
||||
assert result["satisfied"] is False
|
||||
assert result["blocker"] == "missing_evidence"
|
||||
fake_model.ainvoke.assert_not_called()
|
||||
|
||||
|
||||
def test_attach_goal_evaluation_records_blocker_progress_and_stand_down_reason():
|
||||
state = goal.build_goal_state("Finish")
|
||||
evaluation = goal.GoalEvaluation(
|
||||
satisfied=False,
|
||||
blocker="external_wait",
|
||||
reason="Waiting for deployment",
|
||||
evidence_summary="Deploy is pending",
|
||||
)
|
||||
|
||||
updated = goal.attach_goal_evaluation(
|
||||
state,
|
||||
evaluation,
|
||||
run_id="run-1",
|
||||
no_progress_count=1,
|
||||
stand_down_reason="blocked:external_wait",
|
||||
)
|
||||
|
||||
assert updated["no_progress_count"] == 1
|
||||
assert updated["last_evaluation"]["blocker"] == "external_wait"
|
||||
assert updated["last_evaluation"]["evidence_summary"] == "Deploy is pending"
|
||||
assert updated["last_evaluation"]["stand_down_reason"] == "blocked:external_wait"
|
||||
assert updated["last_evaluation"]["progress_key"]
|
||||
|
||||
|
||||
def test_latest_visible_assistant_signature_tracks_last_ai_evidence():
|
||||
base = [HumanMessage(content="go"), AIMessage(content="answer one")]
|
||||
sig1 = goal.latest_visible_assistant_signature(base)
|
||||
# Signature depends only on the latest visible assistant text, not the prompt.
|
||||
sig1_again = goal.latest_visible_assistant_signature([HumanMessage(content="different prompt"), AIMessage(content="answer one")])
|
||||
# It changes when the assistant produces new output.
|
||||
sig2 = goal.latest_visible_assistant_signature([HumanMessage(content="go"), AIMessage(content="answer two")])
|
||||
|
||||
assert sig1 and sig1 == sig1_again
|
||||
assert sig1 != sig2
|
||||
# Hidden continuations and human-only transcripts contribute no evidence.
|
||||
hidden = AIMessage(content="hidden", additional_kwargs={"hide_from_ui": True})
|
||||
assert goal.latest_visible_assistant_signature([HumanMessage(content="only human"), hidden]) == ""
|
||||
|
||||
|
||||
def test_no_progress_count_keys_on_evidence_not_volatile_free_text():
|
||||
"""The breaker must survive the evaluator rewording its reason.
|
||||
|
||||
Same visible assistant evidence + reworded free-text reason/evidence_summary
|
||||
must still count as 'no progress'. The previous implementation keyed on the
|
||||
volatile free-text, so the breaker effectively never fired.
|
||||
"""
|
||||
evidence = "I made a start, but I am not done."
|
||||
first = goal.GoalEvaluation(satisfied=False, blocker="goal_not_met_yet", reason="The same work remains.", evidence_summary="No new verification evidence.")
|
||||
prior = goal.attach_goal_evaluation(goal.build_goal_state("Finish"), first, run_id="r1", no_progress_count=0, evidence_signature=evidence)
|
||||
|
||||
reworded = goal.GoalEvaluation(satisfied=False, blocker="goal_not_met_yet", reason="Still the same outstanding work, phrased differently.", evidence_summary="Evidence remains thin; nothing new verified.")
|
||||
assert goal.compute_no_progress_count(prior, reworded, evidence_signature=evidence) == 1
|
||||
|
||||
|
||||
def test_no_progress_count_resets_when_evidence_advances():
|
||||
first = goal.GoalEvaluation(satisfied=False, blocker="goal_not_met_yet", reason="x", evidence_summary="y")
|
||||
prior = goal.attach_goal_evaluation(goal.build_goal_state("Finish"), first, run_id="r1", no_progress_count=1, evidence_signature="step 1 done")
|
||||
|
||||
# Identical evaluator wording, but the agent produced NEW visible evidence -> progress.
|
||||
assert goal.compute_no_progress_count(prior, first, evidence_signature="step 2 done") == 0
|
||||
|
||||
|
||||
def test_parse_goal_command_status_for_empty_and_whitespace():
|
||||
assert goal.parse_goal_command("") == goal.GoalCommand("status")
|
||||
assert goal.parse_goal_command(" ") == goal.GoalCommand("status")
|
||||
|
||||
|
||||
def test_parse_goal_command_clear_aliases_case_insensitive():
|
||||
for alias in ("clear", "reset", "off", "CLEAR", " Reset ", "Off"):
|
||||
assert goal.parse_goal_command(alias) == goal.GoalCommand("clear")
|
||||
|
||||
|
||||
def test_parse_goal_command_set_trims_and_preserves_objective():
|
||||
assert goal.parse_goal_command(" finish the work ") == goal.GoalCommand("set", "finish the work")
|
||||
# A multi-word objective that merely starts with an alias is a set, not a clear.
|
||||
assert goal.parse_goal_command("clear the build cache") == goal.GoalCommand("set", "clear the build cache")
|
||||
689
backend/tests/test_goal_worker.py
Normal file
689
backend/tests/test_goal_worker.py
Normal file
@ -0,0 +1,689 @@
|
||||
import asyncio
|
||||
import copy
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langgraph.checkpoint.base import empty_checkpoint, uuid6
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
|
||||
from deerflow.runtime.goal import GoalEvaluation, attach_goal_evaluation, build_goal_state, latest_visible_assistant_signature, read_thread_goal, write_thread_goal
|
||||
from deerflow.runtime.runs import worker
|
||||
from deerflow.runtime.runs.manager import RunRecord
|
||||
from deerflow.runtime.runs.schemas import DisconnectMode, RunStatus
|
||||
|
||||
|
||||
class _CollectingBridge:
|
||||
def __init__(self) -> None:
|
||||
self.events: list[tuple[str, object]] = []
|
||||
|
||||
async def publish(self, _run_id: str, event: str, payload: object) -> None:
|
||||
self.events.append((event, payload))
|
||||
|
||||
|
||||
class _ClearBeforeSecondGoalReadCheckpointer:
|
||||
"""Wrap a saver and clear the goal just before the evaluator write rereads.
|
||||
|
||||
The first ``aget_tuple`` is the evaluator's current-goal read. The second is
|
||||
``write_thread_goal`` preparing its read-modify-write. Clearing at that point
|
||||
models a user ``/goal clear`` landing between those two operations.
|
||||
"""
|
||||
|
||||
def __init__(self, inner: InMemorySaver, thread_id: str) -> None:
|
||||
self.inner = inner
|
||||
self.thread_id = thread_id
|
||||
self.read_count = 0
|
||||
self.cleared = False
|
||||
|
||||
def get_next_version(self, current, channel):
|
||||
return self.inner.get_next_version(current, channel)
|
||||
|
||||
async def aget_tuple(self, config):
|
||||
self.read_count += 1
|
||||
if self.read_count == 2 and not self.cleared:
|
||||
self.cleared = True
|
||||
await write_thread_goal(self.inner, self.thread_id, None, as_node="test_clear")
|
||||
return await self.inner.aget_tuple(config)
|
||||
|
||||
async def aput(self, *args, **kwargs):
|
||||
return await self.inner.aput(*args, **kwargs)
|
||||
|
||||
|
||||
async def _seed_goal_thread(
|
||||
checkpointer: InMemorySaver,
|
||||
*,
|
||||
thread_id: str,
|
||||
goal_text: str,
|
||||
messages: list | None = None,
|
||||
) -> None:
|
||||
checkpoint = empty_checkpoint()
|
||||
checkpoint["channel_values"] = {
|
||||
"messages": messages
|
||||
or [
|
||||
HumanMessage(content="Please finish this task."),
|
||||
AIMessage(content="I made a start, but I am not done."),
|
||||
]
|
||||
}
|
||||
checkpoint["channel_versions"] = {"messages": 1}
|
||||
checkpointer.put(
|
||||
{"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}},
|
||||
checkpoint,
|
||||
{"step": 1},
|
||||
{"messages": 1},
|
||||
)
|
||||
await write_thread_goal(checkpointer, thread_id, build_goal_state(goal_text, max_continuations=2))
|
||||
|
||||
|
||||
async def _write_messages(checkpointer: InMemorySaver, *, thread_id: str, messages: list) -> None:
|
||||
checkpoint_tuple = await checkpointer.aget_tuple({"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}})
|
||||
assert checkpoint_tuple is not None
|
||||
checkpoint = copy.deepcopy(getattr(checkpoint_tuple, "checkpoint", {}) or {})
|
||||
metadata = copy.deepcopy(getattr(checkpoint_tuple, "metadata", {}) or {})
|
||||
channel_values = dict(checkpoint.get("channel_values", {}) or {})
|
||||
channel_values["messages"] = messages
|
||||
checkpoint["channel_values"] = channel_values
|
||||
channel_versions = dict(checkpoint.get("channel_versions", {}) or {})
|
||||
current_version = channel_versions.get("messages")
|
||||
channel_versions["messages"] = checkpointer.get_next_version(current_version, None)
|
||||
checkpoint["channel_versions"] = channel_versions
|
||||
checkpoint["id"] = str(uuid6())
|
||||
metadata["step"] = metadata.get("step", 0) + 1
|
||||
metadata["writes"] = {"test": {"messages": messages}}
|
||||
await checkpointer.aput(
|
||||
{"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}},
|
||||
checkpoint,
|
||||
metadata,
|
||||
{"messages": channel_versions["messages"]},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_goal_worker_returns_hidden_continuation_when_goal_is_unmet(monkeypatch):
|
||||
checkpointer = InMemorySaver()
|
||||
thread_id = "goal-thread"
|
||||
await _seed_goal_thread(checkpointer, thread_id=thread_id, goal_text="Finish all tests")
|
||||
bridge = _CollectingBridge()
|
||||
|
||||
async def fake_evaluate_goal_completion(goal, messages, **_kwargs):
|
||||
assert goal["objective"] == "Finish all tests"
|
||||
assert [message.content for message in messages][-1] == "I made a start, but I am not done."
|
||||
return GoalEvaluation(
|
||||
satisfied=False,
|
||||
blocker="goal_not_met_yet",
|
||||
reason="Tests have not passed yet.",
|
||||
evidence_summary="Implementation is incomplete.",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(worker, "evaluate_goal_completion", fake_evaluate_goal_completion)
|
||||
|
||||
continuation = await worker._prepare_goal_continuation_input(
|
||||
bridge=bridge,
|
||||
checkpointer=checkpointer,
|
||||
thread_id=thread_id,
|
||||
run_id="run-1",
|
||||
model_name="test-model",
|
||||
app_config=None,
|
||||
)
|
||||
|
||||
assert continuation is not None
|
||||
[message] = continuation["messages"]
|
||||
assert message.additional_kwargs["hide_from_ui"] is True
|
||||
assert "Finish all tests" in message.content
|
||||
assert "Tests have not passed yet." in message.content
|
||||
latest_goal = await read_thread_goal(checkpointer, thread_id)
|
||||
assert latest_goal is not None
|
||||
assert latest_goal["continuation_count"] == 1
|
||||
assert latest_goal["last_evaluation"]["run_id"] == "run-1"
|
||||
assert latest_goal["last_evaluation"]["blocker"] == "goal_not_met_yet"
|
||||
assert "stand_down_reason" not in latest_goal["last_evaluation"]
|
||||
assert bridge.events[0][0] == "values"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_goal_worker_clears_goal_when_evaluator_is_satisfied(monkeypatch):
|
||||
checkpointer = InMemorySaver()
|
||||
thread_id = "done-goal-thread"
|
||||
await _seed_goal_thread(checkpointer, thread_id=thread_id, goal_text="Finish all tests")
|
||||
bridge = _CollectingBridge()
|
||||
|
||||
async def fake_evaluate_goal_completion(_goal, _messages, **_kwargs):
|
||||
return GoalEvaluation(
|
||||
satisfied=True,
|
||||
blocker="none",
|
||||
reason="The visible conversation says the task is done.",
|
||||
evidence_summary="Done.",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(worker, "evaluate_goal_completion", fake_evaluate_goal_completion)
|
||||
|
||||
continuation = await worker._prepare_goal_continuation_input(
|
||||
bridge=bridge,
|
||||
checkpointer=checkpointer,
|
||||
thread_id=thread_id,
|
||||
run_id="run-2",
|
||||
model_name="test-model",
|
||||
app_config=None,
|
||||
)
|
||||
|
||||
assert continuation is None
|
||||
assert await read_thread_goal(checkpointer, thread_id) is None
|
||||
assert bridge.events[0][0] == "values"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_goal_worker_stands_down_for_non_continuable_blocker(monkeypatch):
|
||||
checkpointer = InMemorySaver()
|
||||
thread_id = "blocked-goal-thread"
|
||||
await _seed_goal_thread(checkpointer, thread_id=thread_id, goal_text="Finish all tests")
|
||||
bridge = _CollectingBridge()
|
||||
|
||||
async def fake_evaluate_goal_completion(_goal, _messages, **_kwargs):
|
||||
return GoalEvaluation(
|
||||
satisfied=False,
|
||||
blocker="missing_evidence",
|
||||
reason="The transcript does not prove any verification.",
|
||||
evidence_summary="No test result is visible.",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(worker, "evaluate_goal_completion", fake_evaluate_goal_completion)
|
||||
|
||||
continuation = await worker._prepare_goal_continuation_input(
|
||||
bridge=bridge,
|
||||
checkpointer=checkpointer,
|
||||
thread_id=thread_id,
|
||||
run_id="run-3",
|
||||
model_name="test-model",
|
||||
app_config=None,
|
||||
)
|
||||
|
||||
assert continuation is None
|
||||
latest_goal = await read_thread_goal(checkpointer, thread_id)
|
||||
assert latest_goal is not None
|
||||
assert latest_goal["continuation_count"] == 0
|
||||
assert latest_goal["last_evaluation"]["blocker"] == "missing_evidence"
|
||||
assert latest_goal["last_evaluation"]["stand_down_reason"] == "blocked:missing_evidence"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_goal_worker_stands_down_when_no_progress_repeats(monkeypatch):
|
||||
checkpointer = InMemorySaver()
|
||||
thread_id = "no-progress-goal-thread"
|
||||
messages = [HumanMessage(content="Please finish this task."), AIMessage(content="I made a start, but I am not done.")]
|
||||
await _seed_goal_thread(checkpointer, thread_id=thread_id, goal_text="Finish all tests", messages=messages)
|
||||
previous_goal = await read_thread_goal(checkpointer, thread_id)
|
||||
assert previous_goal is not None
|
||||
repeated_evaluation = GoalEvaluation(
|
||||
satisfied=False,
|
||||
blocker="goal_not_met_yet",
|
||||
reason="The same work remains.",
|
||||
evidence_summary="No new verification evidence.",
|
||||
)
|
||||
# Seed the prior evaluation with the SAME visible assistant evidence the worker
|
||||
# will recompute, so the no-progress breaker recognises the stalled turn even
|
||||
# though the evaluator may reword its free-text reason.
|
||||
evidence_signature = latest_visible_assistant_signature(messages)
|
||||
await write_thread_goal(
|
||||
checkpointer,
|
||||
thread_id,
|
||||
attach_goal_evaluation(previous_goal, repeated_evaluation, run_id="previous-run", no_progress_count=1, evidence_signature=evidence_signature),
|
||||
)
|
||||
bridge = _CollectingBridge()
|
||||
|
||||
async def fake_evaluate_goal_completion(_goal, _messages, **_kwargs):
|
||||
return repeated_evaluation
|
||||
|
||||
monkeypatch.setattr(worker, "evaluate_goal_completion", fake_evaluate_goal_completion)
|
||||
|
||||
continuation = await worker._prepare_goal_continuation_input(
|
||||
bridge=bridge,
|
||||
checkpointer=checkpointer,
|
||||
thread_id=thread_id,
|
||||
run_id="run-4",
|
||||
model_name="test-model",
|
||||
app_config=None,
|
||||
)
|
||||
|
||||
assert continuation is None
|
||||
latest_goal = await read_thread_goal(checkpointer, thread_id)
|
||||
assert latest_goal is not None
|
||||
assert latest_goal["no_progress_count"] == 2
|
||||
assert latest_goal["last_evaluation"]["stand_down_reason"] == "no_progress_detected"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_goal_worker_does_not_resurrect_goal_cleared_during_evaluation(monkeypatch):
|
||||
checkpointer = InMemorySaver()
|
||||
thread_id = "clear-during-eval-thread"
|
||||
await _seed_goal_thread(checkpointer, thread_id=thread_id, goal_text="Finish all tests")
|
||||
bridge = _CollectingBridge()
|
||||
|
||||
async def fake_evaluate_goal_completion(_goal, _messages, **_kwargs):
|
||||
await write_thread_goal(checkpointer, thread_id, None, as_node="test")
|
||||
return GoalEvaluation(
|
||||
satisfied=False,
|
||||
blocker="goal_not_met_yet",
|
||||
reason="More work remains.",
|
||||
evidence_summary="Work remains.",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(worker, "evaluate_goal_completion", fake_evaluate_goal_completion)
|
||||
|
||||
continuation = await worker._prepare_goal_continuation_input(
|
||||
bridge=bridge,
|
||||
checkpointer=checkpointer,
|
||||
thread_id=thread_id,
|
||||
run_id="run-5",
|
||||
model_name="test-model",
|
||||
app_config=None,
|
||||
)
|
||||
|
||||
assert continuation is None
|
||||
assert await read_thread_goal(checkpointer, thread_id) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_goal_worker_does_not_resurrect_goal_cleared_during_persist():
|
||||
checkpointer = InMemorySaver()
|
||||
thread_id = "clear-during-persist-thread"
|
||||
await _seed_goal_thread(checkpointer, thread_id=thread_id, goal_text="Finish all tests")
|
||||
existing_goal = await read_thread_goal(checkpointer, thread_id)
|
||||
assert existing_goal is not None
|
||||
wrapped_checkpointer = _ClearBeforeSecondGoalReadCheckpointer(checkpointer, thread_id)
|
||||
bridge = _CollectingBridge()
|
||||
|
||||
result = await worker._persist_goal_evaluation(
|
||||
bridge=bridge,
|
||||
checkpointer=wrapped_checkpointer,
|
||||
thread_id=thread_id,
|
||||
run_id="run-clear-during-persist",
|
||||
goal=existing_goal,
|
||||
evaluation=GoalEvaluation(
|
||||
satisfied=False,
|
||||
blocker="goal_not_met_yet",
|
||||
reason="More work remains.",
|
||||
evidence_summary="Work remains.",
|
||||
),
|
||||
no_progress_count=0,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert wrapped_checkpointer.cleared is True
|
||||
assert await read_thread_goal(checkpointer, thread_id) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_goal_worker_stops_when_abort_is_requested_during_evaluation(monkeypatch):
|
||||
checkpointer = InMemorySaver()
|
||||
thread_id = "abort-during-eval-thread"
|
||||
await _seed_goal_thread(checkpointer, thread_id=thread_id, goal_text="Finish all tests")
|
||||
bridge = _CollectingBridge()
|
||||
abort_event = asyncio.Event()
|
||||
|
||||
async def fake_evaluate_goal_completion(_goal, _messages, **_kwargs):
|
||||
abort_event.set()
|
||||
return GoalEvaluation(
|
||||
satisfied=False,
|
||||
blocker="goal_not_met_yet",
|
||||
reason="More work remains.",
|
||||
evidence_summary="Work remains.",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(worker, "evaluate_goal_completion", fake_evaluate_goal_completion)
|
||||
|
||||
continuation = await worker._prepare_goal_continuation_input(
|
||||
bridge=bridge,
|
||||
checkpointer=checkpointer,
|
||||
thread_id=thread_id,
|
||||
run_id="run-abort",
|
||||
model_name="test-model",
|
||||
app_config=None,
|
||||
abort_event=abort_event,
|
||||
)
|
||||
|
||||
assert continuation is None
|
||||
latest_goal = await read_thread_goal(checkpointer, thread_id)
|
||||
assert latest_goal is not None
|
||||
assert latest_goal["continuation_count"] == 0
|
||||
assert "last_evaluation" not in latest_goal
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_goal_worker_stands_down_when_thread_changes_after_evaluation(monkeypatch):
|
||||
checkpointer = InMemorySaver()
|
||||
thread_id = "user-wins-thread"
|
||||
await _seed_goal_thread(checkpointer, thread_id=thread_id, goal_text="Finish all tests")
|
||||
bridge = _CollectingBridge()
|
||||
|
||||
async def fake_evaluate_goal_completion(_goal, messages, **_kwargs):
|
||||
await _write_messages(
|
||||
checkpointer,
|
||||
thread_id=thread_id,
|
||||
messages=[*messages, HumanMessage(content="Actually, stop and wait.")],
|
||||
)
|
||||
return GoalEvaluation(
|
||||
satisfied=False,
|
||||
blocker="goal_not_met_yet",
|
||||
reason="More work remains.",
|
||||
evidence_summary="Work remains.",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(worker, "evaluate_goal_completion", fake_evaluate_goal_completion)
|
||||
|
||||
continuation = await worker._prepare_goal_continuation_input(
|
||||
bridge=bridge,
|
||||
checkpointer=checkpointer,
|
||||
thread_id=thread_id,
|
||||
run_id="run-6",
|
||||
model_name="test-model",
|
||||
app_config=None,
|
||||
)
|
||||
|
||||
assert continuation is None
|
||||
latest_goal = await read_thread_goal(checkpointer, thread_id)
|
||||
assert latest_goal is not None
|
||||
assert latest_goal["continuation_count"] == 0
|
||||
assert latest_goal["last_evaluation"]["stand_down_reason"] == "thread_changed_after_evaluation"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_goal_worker_stands_down_without_durable_assistant_receipt():
|
||||
checkpointer = InMemorySaver()
|
||||
thread_id = "no-receipt-thread"
|
||||
await _seed_goal_thread(
|
||||
checkpointer,
|
||||
thread_id=thread_id,
|
||||
goal_text="Finish all tests",
|
||||
messages=[HumanMessage(content="Please finish this task.")],
|
||||
)
|
||||
bridge = _CollectingBridge()
|
||||
|
||||
continuation = await worker._prepare_goal_continuation_input(
|
||||
bridge=bridge,
|
||||
checkpointer=checkpointer,
|
||||
thread_id=thread_id,
|
||||
run_id="run-7",
|
||||
model_name="test-model",
|
||||
app_config=None,
|
||||
)
|
||||
|
||||
assert continuation is None
|
||||
latest_goal = await read_thread_goal(checkpointer, thread_id)
|
||||
assert latest_goal is not None
|
||||
assert latest_goal["last_evaluation"]["blocker"] == "run_failed"
|
||||
assert latest_goal["last_evaluation"]["stand_down_reason"] == "no_durable_end_of_turn"
|
||||
|
||||
|
||||
def test_stand_down_reason_uses_documented_default_caps_when_missing():
|
||||
"""_stand_down_reason must fall back to the same default caps as
|
||||
should_continue_goal (8 / 2). A bare goal dict missing the cap fields must
|
||||
not be reported as 'max reached' / 'no progress' when it has not actually
|
||||
exhausted the documented defaults.
|
||||
"""
|
||||
bare_goal = {"objective": "x", "status": "active", "continuation_count": 0}
|
||||
unmet = GoalEvaluation(satisfied=False, blocker="goal_not_met_yet", reason="", evidence_summary="")
|
||||
|
||||
assert worker._stand_down_reason(bare_goal, unmet, no_progress_count=0) is None
|
||||
# And the two gate functions agree on the same bare goal.
|
||||
from deerflow.runtime.goal import should_continue_goal
|
||||
|
||||
assert should_continue_goal(bare_goal, unmet, no_progress_count=0) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_agent_does_not_stream_continuation_after_abort(monkeypatch):
|
||||
class FakeAgent:
|
||||
def __init__(self) -> None:
|
||||
self.inputs = []
|
||||
self.metadata = {}
|
||||
self.checkpointer = None
|
||||
self.store = None
|
||||
self.interrupt_before_nodes = []
|
||||
self.interrupt_after_nodes = []
|
||||
|
||||
def astream(self, input_payload, **_kwargs):
|
||||
self.inputs.append(input_payload)
|
||||
|
||||
async def _gen():
|
||||
yield {"messages": []}
|
||||
|
||||
return _gen()
|
||||
|
||||
class FakeRunManager:
|
||||
async def set_status(self, _run_id, status, **_kwargs):
|
||||
record.status = status
|
||||
|
||||
async def update_model_name(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def update_run_completion(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def wait_for_prior_finalizing(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def set_finalizing(self, _run_id, finalizing):
|
||||
record.finalizing = finalizing
|
||||
|
||||
class FakeBridge:
|
||||
async def publish(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def publish_end(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def cleanup(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def fake_prepare(**kwargs):
|
||||
kwargs["abort_event"].set()
|
||||
return {"messages": [HumanMessage(content="continue", additional_kwargs={"hide_from_ui": True})]}
|
||||
|
||||
monkeypatch.setattr(worker, "_prepare_goal_continuation_input", fake_prepare)
|
||||
|
||||
fake_agent = FakeAgent()
|
||||
record = RunRecord(
|
||||
run_id="run-abort-loop",
|
||||
thread_id="thread-abort-loop",
|
||||
assistant_id="lead-agent",
|
||||
status=RunStatus.pending,
|
||||
on_disconnect=DisconnectMode.cancel,
|
||||
model_name="test-model",
|
||||
)
|
||||
record.abort_event = asyncio.Event()
|
||||
|
||||
await worker.run_agent(
|
||||
FakeBridge(),
|
||||
FakeRunManager(),
|
||||
record,
|
||||
ctx=worker.RunContext(checkpointer=None),
|
||||
agent_factory=lambda config: fake_agent,
|
||||
graph_input={"messages": [HumanMessage(content="start")]},
|
||||
config={"configurable": {"thread_id": "thread-abort-loop"}},
|
||||
)
|
||||
|
||||
assert len(fake_agent.inputs) == 1
|
||||
assert fake_agent.inputs[0] == {"messages": [HumanMessage(content="start")]}
|
||||
assert record.status == RunStatus.interrupted
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_agent_reuses_goal_evaluator_model_for_goal_loop(monkeypatch):
|
||||
class FakeAgent:
|
||||
def __init__(self) -> None:
|
||||
self.inputs = []
|
||||
self.metadata = {}
|
||||
self.checkpointer = None
|
||||
self.store = None
|
||||
self.interrupt_before_nodes = []
|
||||
self.interrupt_after_nodes = []
|
||||
|
||||
def astream(self, input_payload, **_kwargs):
|
||||
self.inputs.append(input_payload)
|
||||
|
||||
async def _gen():
|
||||
yield {"messages": []}
|
||||
|
||||
return _gen()
|
||||
|
||||
class FakeRunManager:
|
||||
async def set_status(self, _run_id, status, **_kwargs):
|
||||
record.status = status
|
||||
|
||||
async def update_model_name(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def update_run_completion(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def wait_for_prior_finalizing(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def set_finalizing(self, _run_id, finalizing):
|
||||
record.finalizing = finalizing
|
||||
|
||||
class FakeBridge:
|
||||
async def publish(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def publish_end(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def cleanup(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
evaluator_model = object()
|
||||
create_calls = []
|
||||
|
||||
def fake_create_goal_evaluator_model(**kwargs):
|
||||
create_calls.append(kwargs)
|
||||
return evaluator_model
|
||||
|
||||
prepare_models = []
|
||||
|
||||
async def fake_prepare(**kwargs):
|
||||
prepare_models.append(kwargs["evaluator_model_factory"]())
|
||||
if len(prepare_models) == 1:
|
||||
return {"messages": [HumanMessage(content="continue", additional_kwargs={"hide_from_ui": True})]}
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(worker, "create_goal_evaluator_model", fake_create_goal_evaluator_model)
|
||||
monkeypatch.setattr(worker, "_prepare_goal_continuation_input", fake_prepare)
|
||||
|
||||
fake_agent = FakeAgent()
|
||||
record = RunRecord(
|
||||
run_id="run-model-cache",
|
||||
thread_id="thread-model-cache",
|
||||
assistant_id="lead-agent",
|
||||
status=RunStatus.pending,
|
||||
on_disconnect=DisconnectMode.cancel,
|
||||
model_name="test-model",
|
||||
)
|
||||
record.abort_event = asyncio.Event()
|
||||
|
||||
await worker.run_agent(
|
||||
FakeBridge(),
|
||||
FakeRunManager(),
|
||||
record,
|
||||
ctx=worker.RunContext(checkpointer=None, app_config=object()),
|
||||
agent_factory=lambda config: fake_agent,
|
||||
graph_input={"messages": [HumanMessage(content="start")]},
|
||||
config={"configurable": {"thread_id": "thread-model-cache"}},
|
||||
)
|
||||
|
||||
assert len(fake_agent.inputs) == 2
|
||||
assert prepare_models == [evaluator_model, evaluator_model]
|
||||
assert len(create_calls) == 1
|
||||
assert create_calls[0]["model_name"] == "test-model"
|
||||
assert record.status == RunStatus.success
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_agent_strips_branch_checkpoint_for_goal_continuation(monkeypatch):
|
||||
class FakeAgent:
|
||||
def __init__(self) -> None:
|
||||
self.calls = []
|
||||
self.metadata = {}
|
||||
self.checkpointer = None
|
||||
self.store = None
|
||||
self.interrupt_before_nodes = []
|
||||
self.interrupt_after_nodes = []
|
||||
|
||||
def astream(self, input_payload, **kwargs):
|
||||
configurable = dict(kwargs["config"].get("configurable", {}))
|
||||
self.calls.append((input_payload, configurable))
|
||||
|
||||
async def _gen():
|
||||
yield {"messages": []}
|
||||
|
||||
return _gen()
|
||||
|
||||
class FakeRunManager:
|
||||
async def set_status(self, _run_id, status, **_kwargs):
|
||||
record.status = status
|
||||
|
||||
async def update_model_name(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def update_run_completion(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def wait_for_prior_finalizing(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def set_finalizing(self, _run_id, finalizing):
|
||||
record.finalizing = finalizing
|
||||
|
||||
class FakeBridge:
|
||||
async def publish(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def publish_end(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def cleanup(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def fake_prepare(**_kwargs):
|
||||
if len(fake_agent.calls) == 1:
|
||||
return {"messages": [HumanMessage(content="continue", additional_kwargs={"hide_from_ui": True})]}
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(worker, "_prepare_goal_continuation_input", fake_prepare)
|
||||
|
||||
fake_agent = FakeAgent()
|
||||
record = RunRecord(
|
||||
run_id="run-branch-continuation",
|
||||
thread_id="thread-branch-continuation",
|
||||
assistant_id="lead-agent",
|
||||
status=RunStatus.pending,
|
||||
on_disconnect=DisconnectMode.cancel,
|
||||
model_name="test-model",
|
||||
)
|
||||
record.abort_event = asyncio.Event()
|
||||
|
||||
await worker.run_agent(
|
||||
FakeBridge(),
|
||||
FakeRunManager(),
|
||||
record,
|
||||
ctx=worker.RunContext(checkpointer=None),
|
||||
agent_factory=lambda config: fake_agent,
|
||||
graph_input={"messages": [HumanMessage(content="start")]},
|
||||
config={
|
||||
"configurable": {
|
||||
"thread_id": "thread-branch-continuation",
|
||||
"checkpoint_ns": "branch",
|
||||
"checkpoint_id": "old-checkpoint",
|
||||
"checkpoint_map": {"": "old-checkpoint"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert len(fake_agent.calls) == 2
|
||||
first_config = fake_agent.calls[0][1]
|
||||
second_config = fake_agent.calls[1][1]
|
||||
assert first_config["checkpoint_ns"] == "branch"
|
||||
assert first_config["checkpoint_id"] == "old-checkpoint"
|
||||
assert first_config["checkpoint_map"] == {"": "old-checkpoint"}
|
||||
assert second_config["checkpoint_ns"] == ""
|
||||
assert "checkpoint_id" not in second_config
|
||||
assert "checkpoint_map" not in second_config
|
||||
assert second_config["thread_id"] == "thread-branch-continuation"
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -42,7 +42,7 @@ Frontend (Next.js) ──▶ LangGraph SDK ──▶ LangGraph Backend (lead_age
|
||||
└── Tools & Skills
|
||||
```
|
||||
|
||||
The frontend is a stateful chat application. Users create **threads** (conversations), send messages, and receive streamed AI responses. The backend orchestrates agents that can produce **artifacts** (files/code) and **todos**.
|
||||
The frontend is a stateful chat application. Users create **threads** (conversations), send messages, set thread-scoped `/goal` completion conditions, and receive streamed AI responses. The backend orchestrates agents that can produce **artifacts** (files/code), **todos**, and goal state updates.
|
||||
|
||||
### Source Layout (`src/`)
|
||||
|
||||
@ -64,11 +64,13 @@ The frontend is a stateful chat application. Users create **threads** (conversat
|
||||
### Data Flow
|
||||
|
||||
1. User input → thread hooks (`core/threads/hooks.ts`) → LangGraph SDK streaming
|
||||
2. Stream events update thread state (messages, artifacts, todos)
|
||||
2. Stream events update thread state (messages, artifacts, todos, goal)
|
||||
3. Stop actions call the LangGraph SDK stream stop path; `core/threads/hooks.ts` invalidates current-thread, token-usage, and sidebar/search caches immediately and schedules one follow-up refetch because SDK stop may finish via abort + fire-and-forget cancel before backend title finalization commits
|
||||
4. TanStack Query manages server state; localStorage stores user settings
|
||||
5. Components subscribe to thread state and render updates
|
||||
|
||||
`/goal` is a built-in composer command, not a skill activation. `src/components/workspace/input-box.tsx` intercepts `/goal`, `/goal clear`, and `/goal <condition>` before normal chat submission, calling Gateway `GET/PUT/DELETE /api/threads/{thread_id}/goal`. Setting `/goal <condition>` also submits the condition text as the next user task so the agent starts running immediately; status and clear do not start a run. Goal requests are tied to the current `threadId` with an `AbortController`, so switching threads or unmounting the composer aborts in-flight goal requests and stale responses cannot update the new thread's goal state. The chat pages render `GoalStatus` above the composer from `AgentThreadState.goal`, with local optimistic state until the next stream `values` update arrives.
|
||||
|
||||
### Key Patterns
|
||||
|
||||
- **Server Components by default**, `"use client"` only for interactive components
|
||||
@ -80,6 +82,7 @@ The frontend is a stateful chat application. Users create **threads** (conversat
|
||||
### Interaction Ownership
|
||||
|
||||
- `src/app/workspace/chats/[thread_id]/page.tsx` owns composer busy-state wiring.
|
||||
- `src/app/workspace/chats/[thread_id]/page.tsx` and `src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx` own active-goal display state for their composer overlays.
|
||||
- `src/core/threads/hooks.ts` owns pre-submit upload state and thread submission.
|
||||
|
||||
## Code Style
|
||||
|
||||
@ -11,6 +11,7 @@ import { AgentWelcome } from "@/components/workspace/agent-welcome";
|
||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
|
||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||
import { GoalStatus } from "@/components/workspace/goal-status";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import {
|
||||
MessageList,
|
||||
@ -21,6 +22,7 @@ import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||
import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import { useActiveGoal } from "@/components/workspace/use-active-goal";
|
||||
import { useAgent } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
@ -162,6 +164,10 @@ export default function AgentChatPage() {
|
||||
? localSettings.tokenUsage.inlineMode
|
||||
: "off";
|
||||
const hasTodos = (thread.values.todos?.length ?? 0) > 0;
|
||||
const { activeGoal, hasGoal, setLocalGoal } = useActiveGoal(
|
||||
threadId,
|
||||
thread.values.goal,
|
||||
);
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
@ -247,7 +253,7 @@ export default function AgentChatPage() {
|
||||
: "max-w-(--container-width-md)",
|
||||
)}
|
||||
>
|
||||
{hasTodos && (
|
||||
{(hasGoal || hasTodos) && (
|
||||
<div
|
||||
className={cn(
|
||||
"right-0 left-0 z-0",
|
||||
@ -256,15 +262,18 @@ export default function AgentChatPage() {
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"right-0 bottom-0 left-0",
|
||||
"right-0 bottom-0 left-0 flex flex-col",
|
||||
isWelcomeMode ? "absolute" : "relative",
|
||||
)}
|
||||
>
|
||||
<TodoList
|
||||
className="bg-background/5"
|
||||
todos={thread.values.todos ?? []}
|
||||
hidden={false}
|
||||
/>
|
||||
{activeGoal && <GoalStatus goal={activeGoal} />}
|
||||
{hasTodos && (
|
||||
<TodoList
|
||||
className="bg-background/5"
|
||||
todos={thread.values.todos ?? []}
|
||||
hidden={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -286,7 +295,9 @@ export default function AgentChatPage() {
|
||||
}
|
||||
context={settings.context}
|
||||
extraHeader={
|
||||
isWelcomeMode && (
|
||||
isWelcomeMode &&
|
||||
!hasGoal &&
|
||||
!hasTodos && (
|
||||
<AgentWelcome agent={agent} agentName={agent_name} />
|
||||
)
|
||||
}
|
||||
@ -295,6 +306,7 @@ export default function AgentChatPage() {
|
||||
isUploading
|
||||
}
|
||||
onContextChange={(context) => setSettings("context", context)}
|
||||
onGoalChange={setLocalGoal}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
useThreadChat,
|
||||
} from "@/components/workspace/chats";
|
||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||
import { GoalStatus } from "@/components/workspace/goal-status";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import {
|
||||
MessageList,
|
||||
@ -21,6 +22,7 @@ import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||
import { useActiveGoal } from "@/components/workspace/use-active-goal";
|
||||
import { Welcome } from "@/components/workspace/welcome";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
@ -170,6 +172,10 @@ export default function ChatPage() {
|
||||
? localSettings.tokenUsage.inlineMode
|
||||
: "off";
|
||||
const hasTodos = (thread.values.todos?.length ?? 0) > 0;
|
||||
const { activeGoal, hasGoal, setLocalGoal } = useActiveGoal(
|
||||
threadId,
|
||||
thread.values.goal,
|
||||
);
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread, isMock }}>
|
||||
@ -240,7 +246,7 @@ export default function ChatPage() {
|
||||
: "max-w-(--container-width-md)",
|
||||
)}
|
||||
>
|
||||
{hasTodos && (
|
||||
{(hasGoal || hasTodos) && (
|
||||
<div
|
||||
className={cn(
|
||||
"right-0 left-0 z-0",
|
||||
@ -249,15 +255,18 @@ export default function ChatPage() {
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"right-0 bottom-0 left-0",
|
||||
"right-0 bottom-0 left-0 flex flex-col",
|
||||
isWelcomeMode ? "absolute" : "relative",
|
||||
)}
|
||||
>
|
||||
<TodoList
|
||||
className="bg-background/5"
|
||||
todos={thread.values.todos ?? []}
|
||||
hidden={false}
|
||||
/>
|
||||
{activeGoal && <GoalStatus goal={activeGoal} />}
|
||||
{hasTodos && (
|
||||
<TodoList
|
||||
className="bg-background/5"
|
||||
todos={thread.values.todos ?? []}
|
||||
hidden={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -279,7 +288,9 @@ export default function ChatPage() {
|
||||
}
|
||||
context={settings.context}
|
||||
extraHeader={
|
||||
isWelcomeMode && <Welcome mode={settings.context.mode} />
|
||||
isWelcomeMode &&
|
||||
!hasGoal &&
|
||||
!hasTodos && <Welcome mode={settings.context.mode} />
|
||||
}
|
||||
disabled={
|
||||
isMock ||
|
||||
@ -289,6 +300,7 @@ export default function ChatPage() {
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
onGoalChange={setLocalGoal}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
|
||||
47
frontend/src/components/workspace/goal-status-helpers.ts
Normal file
47
frontend/src/components/workspace/goal-status-helpers.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import type { GoalState } from "@/core/threads";
|
||||
|
||||
export type GoalContinuationDisplay = {
|
||||
count: number;
|
||||
max: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Decide the continuation counter to render for an active goal.
|
||||
*
|
||||
* Returns `null` until the agent has actually auto-continued at least once
|
||||
* (`continuation_count > 0`). Before that, the raw "0/8" reads as a mysterious
|
||||
* score, so the counter is hidden; once continuation starts it surfaces as
|
||||
* "{count}/{max}" with an explanatory tooltip.
|
||||
*/
|
||||
export function getGoalContinuationDisplay(
|
||||
goal: Pick<GoalState, "continuation_count" | "max_continuations">,
|
||||
): GoalContinuationDisplay | null {
|
||||
const count = goal.continuation_count ?? 0;
|
||||
const max = goal.max_continuations ?? 0;
|
||||
if (!Number.isFinite(count) || count <= 0) {
|
||||
return null;
|
||||
}
|
||||
return { count, max };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable signature of the *server* goal, used to decide when an optimistic
|
||||
* client override should yield back to server state.
|
||||
*
|
||||
* It changes whenever a new goal is set (`created_at`), the agent auto-continues
|
||||
* (`continuation_count`/`updated_at`), or the backend clears/satisfies the goal
|
||||
* (`null`). `useActiveGoal` resets its optimistic copy when this key changes, so
|
||||
* the streamed continuation counter is never permanently shadowed.
|
||||
*/
|
||||
export function goalReconciliationKey(goal: GoalState | null): string {
|
||||
if (!goal) {
|
||||
return "none";
|
||||
}
|
||||
return [
|
||||
goal.objective,
|
||||
goal.status,
|
||||
goal.created_at ?? "",
|
||||
goal.updated_at ?? "",
|
||||
goal.continuation_count ?? 0,
|
||||
].join("|");
|
||||
}
|
||||
51
frontend/src/components/workspace/goal-status.tsx
Normal file
51
frontend/src/components/workspace/goal-status.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { RefreshCwIcon, TargetIcon } from "lucide-react";
|
||||
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import type { GoalState } from "@/core/threads";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { getGoalContinuationDisplay } from "./goal-status-helpers";
|
||||
import { Tooltip } from "./tooltip";
|
||||
|
||||
export function GoalStatus({
|
||||
className,
|
||||
goal,
|
||||
}: {
|
||||
className?: string;
|
||||
goal: GoalState;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const continuation = getGoalContinuationDisplay(goal);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background/90 border-border flex min-h-10 w-full items-center gap-3 rounded-t-xl border border-b-0 px-4 py-2 text-sm shadow-sm backdrop-blur-sm",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<TargetIcon className="text-primary size-4 shrink-0" />
|
||||
<div className="min-w-0 flex-1 truncate">
|
||||
<span className="text-muted-foreground mr-2">
|
||||
{t.inputBox.goalLabel}
|
||||
</span>
|
||||
<span className="font-medium">{goal.objective}</span>
|
||||
</div>
|
||||
{continuation && (
|
||||
<Tooltip
|
||||
content={t.inputBox.goalContinuationTooltip
|
||||
.replace("{count}", String(continuation.count))
|
||||
.replace("{max}", String(continuation.max))}
|
||||
>
|
||||
<span className="text-muted-foreground flex shrink-0 items-center gap-1 text-xs tabular-nums">
|
||||
<RefreshCwIcon className="size-3" />
|
||||
{t.inputBox.goalContinuing
|
||||
.replace("{count}", String(continuation.count))
|
||||
.replace("{max}", String(continuation.max))}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
232
frontend/src/components/workspace/input-box-helpers.ts
Normal file
232
frontend/src/components/workspace/input-box-helpers.ts
Normal file
@ -0,0 +1,232 @@
|
||||
import type { Skill } from "@/core/skills";
|
||||
|
||||
export const MAX_SKILL_SUGGESTIONS = 6;
|
||||
|
||||
export const SUGGESTION_TEMPLATE_PLACEHOLDER_PATTERN =
|
||||
/\[(?:主题|来源|topic|source)\]/i;
|
||||
|
||||
export type SlashSuggestion = {
|
||||
name: string;
|
||||
description: string;
|
||||
kind: "builtin" | "skill";
|
||||
};
|
||||
|
||||
export type GoalCommand =
|
||||
| { kind: "status" }
|
||||
| { kind: "clear" }
|
||||
| { kind: "set"; objective: string };
|
||||
|
||||
export type InputSubmitAction =
|
||||
| { kind: "goal"; command: GoalCommand }
|
||||
| { kind: "stop" }
|
||||
| { kind: "empty" }
|
||||
| { kind: "message" };
|
||||
|
||||
export type GoalRequestState = {
|
||||
controller: AbortController | null;
|
||||
sequence: number;
|
||||
threadId: string | null;
|
||||
};
|
||||
|
||||
export type ActiveGoalRequest = {
|
||||
controller: AbortController;
|
||||
sequence: number;
|
||||
threadId: string;
|
||||
};
|
||||
|
||||
export function createGoalRequestState(): GoalRequestState {
|
||||
return {
|
||||
controller: null,
|
||||
sequence: 0,
|
||||
threadId: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function beginGoalRequest(
|
||||
state: GoalRequestState,
|
||||
threadId: string,
|
||||
): ActiveGoalRequest {
|
||||
state.controller?.abort();
|
||||
const controller = new AbortController();
|
||||
const request = {
|
||||
controller,
|
||||
sequence: state.sequence + 1,
|
||||
threadId,
|
||||
};
|
||||
state.controller = controller;
|
||||
state.sequence = request.sequence;
|
||||
state.threadId = threadId;
|
||||
return request;
|
||||
}
|
||||
|
||||
export function abortGoalRequest(state: GoalRequestState): void {
|
||||
state.controller?.abort();
|
||||
state.controller = null;
|
||||
state.sequence += 1;
|
||||
state.threadId = null;
|
||||
}
|
||||
|
||||
export function finishGoalRequest(
|
||||
state: GoalRequestState,
|
||||
request: ActiveGoalRequest,
|
||||
): void {
|
||||
if (
|
||||
state.controller === request.controller &&
|
||||
state.sequence === request.sequence
|
||||
) {
|
||||
state.controller = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isCurrentGoalRequest(
|
||||
state: GoalRequestState,
|
||||
request: ActiveGoalRequest,
|
||||
threadId: string,
|
||||
): boolean {
|
||||
return (
|
||||
state.controller === request.controller &&
|
||||
state.sequence === request.sequence &&
|
||||
state.threadId === threadId &&
|
||||
!request.controller.signal.aborted
|
||||
);
|
||||
}
|
||||
|
||||
export function isAbortError(error: unknown): boolean {
|
||||
return (
|
||||
(error instanceof DOMException && error.name === "AbortError") ||
|
||||
(typeof error === "object" &&
|
||||
error !== null &&
|
||||
Reflect.get(error, "name") === "AbortError")
|
||||
);
|
||||
}
|
||||
|
||||
export function findSuggestionTemplatePlaceholder(text: string) {
|
||||
const match = SUGGESTION_TEMPLATE_PLACEHOLDER_PATTERN.exec(text);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
};
|
||||
}
|
||||
|
||||
export function getLeadingSlashSkillQuery(value: string): string | null {
|
||||
if (!value.startsWith("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const query = value.slice(1);
|
||||
if (query.includes("/") || /\s/.test(query)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
export function getMatchingSkillSuggestions(
|
||||
skills: Skill[],
|
||||
query: string,
|
||||
builtinCommands: SlashSuggestion[],
|
||||
): SlashSuggestion[] {
|
||||
const normalizedQuery = query.toLowerCase();
|
||||
const builtinCommandNames = new Set(
|
||||
builtinCommands.map(({ name }) => name.toLowerCase()),
|
||||
);
|
||||
|
||||
const builtinMatches = builtinCommands.filter(({ name, description }) => {
|
||||
if (!normalizedQuery) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
name.toLowerCase().includes(normalizedQuery) ||
|
||||
description.toLowerCase().includes(normalizedQuery)
|
||||
);
|
||||
});
|
||||
|
||||
const skillMatches = skills
|
||||
.map((skill, index) => ({
|
||||
skill,
|
||||
index,
|
||||
name: skill.name.toLowerCase(),
|
||||
}))
|
||||
.filter(({ skill, name }) => {
|
||||
if (!skill.enabled) {
|
||||
return false;
|
||||
}
|
||||
if (builtinCommandNames.has(name)) {
|
||||
return false;
|
||||
}
|
||||
return !normalizedQuery || name.includes(normalizedQuery);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aStartsWith = a.name.startsWith(normalizedQuery);
|
||||
const bStartsWith = b.name.startsWith(normalizedQuery);
|
||||
if (aStartsWith !== bStartsWith) {
|
||||
return aStartsWith ? -1 : 1;
|
||||
}
|
||||
return a.index - b.index;
|
||||
})
|
||||
.slice(0, MAX_SKILL_SUGGESTIONS)
|
||||
.map(({ skill }) => ({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
kind: "skill" as const,
|
||||
}));
|
||||
|
||||
return [...skillMatches, ...builtinMatches].slice(0, MAX_SKILL_SUGGESTIONS);
|
||||
}
|
||||
|
||||
export function parseGoalCommand(value: string): GoalCommand | null {
|
||||
const trimmed = value.trim();
|
||||
const match = /^\/goal(?:\s+|$)/i.exec(trimmed);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const args = trimmed.slice(match[0].length).trim();
|
||||
if (!args) {
|
||||
return { kind: "status" };
|
||||
}
|
||||
if (["clear", "reset", "off"].includes(args.toLowerCase())) {
|
||||
return { kind: "clear" };
|
||||
}
|
||||
return { kind: "set", objective: args };
|
||||
}
|
||||
|
||||
export function getInputSubmitAction({
|
||||
text,
|
||||
fileCount,
|
||||
status,
|
||||
}: {
|
||||
text: string;
|
||||
fileCount: number;
|
||||
status: string;
|
||||
}): InputSubmitAction {
|
||||
const goalCommand = parseGoalCommand(text);
|
||||
if (goalCommand && fileCount === 0) {
|
||||
return { kind: "goal", command: goalCommand };
|
||||
}
|
||||
if (status === "streaming") {
|
||||
return { kind: "stop" };
|
||||
}
|
||||
if (!text.trim() && fileCount === 0) {
|
||||
return { kind: "empty" };
|
||||
}
|
||||
return { kind: "message" };
|
||||
}
|
||||
|
||||
export async function readGoalResponseError(
|
||||
response: Response,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const body = (await response.json()) as { detail?: unknown };
|
||||
if (typeof body.detail === "string") {
|
||||
return body.detail;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to generic message.
|
||||
}
|
||||
return `HTTP ${response.status}`;
|
||||
}
|
||||
@ -9,6 +9,7 @@ import {
|
||||
PlusIcon,
|
||||
SparklesIcon,
|
||||
RocketIcon,
|
||||
TargetIcon,
|
||||
XIcon,
|
||||
ZapIcon,
|
||||
} from "lucide-react";
|
||||
@ -63,10 +64,9 @@ import { getBackendBaseURL } from "@/core/config";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { isHiddenFromUIMessage } from "@/core/messages/utils";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import type { Skill } from "@/core/skills";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { useSuggestionsConfig } from "@/core/suggestions/hooks";
|
||||
import type { AgentThreadContext } from "@/core/threads";
|
||||
import type { AgentThreadContext, GoalState } from "@/core/threads";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import {
|
||||
formatUploadSize,
|
||||
@ -95,68 +95,27 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
|
||||
import {
|
||||
abortGoalRequest,
|
||||
beginGoalRequest,
|
||||
createGoalRequestState,
|
||||
findSuggestionTemplatePlaceholder,
|
||||
finishGoalRequest,
|
||||
getInputSubmitAction,
|
||||
getLeadingSlashSkillQuery,
|
||||
getMatchingSkillSuggestions,
|
||||
type GoalCommand,
|
||||
isAbortError,
|
||||
isCurrentGoalRequest,
|
||||
readGoalResponseError,
|
||||
type SlashSuggestion,
|
||||
} from "./input-box-helpers";
|
||||
import { useThread } from "./messages/context";
|
||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||
import { Tooltip } from "./tooltip";
|
||||
|
||||
type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
||||
|
||||
const MAX_SKILL_SUGGESTIONS = 6;
|
||||
const SUGGESTION_TEMPLATE_PLACEHOLDER_PATTERN =
|
||||
/\[(?:主题|来源|topic|source)\]/i;
|
||||
|
||||
function findSuggestionTemplatePlaceholder(text: string) {
|
||||
const match = SUGGESTION_TEMPLATE_PLACEHOLDER_PATTERN.exec(text);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
};
|
||||
}
|
||||
|
||||
function getLeadingSlashSkillQuery(value: string): string | null {
|
||||
if (!value.startsWith("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const query = value.slice(1);
|
||||
if (query.includes("/") || /\s/.test(query)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
function getMatchingSkillSuggestions(skills: Skill[], query: string): Skill[] {
|
||||
const normalizedQuery = query.toLowerCase();
|
||||
|
||||
return skills
|
||||
.map((skill, index) => ({
|
||||
skill,
|
||||
index,
|
||||
name: skill.name.toLowerCase(),
|
||||
}))
|
||||
.filter(({ skill, name }) => {
|
||||
if (!skill.enabled) {
|
||||
return false;
|
||||
}
|
||||
return !normalizedQuery || name.includes(normalizedQuery);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aStartsWith = a.name.startsWith(normalizedQuery);
|
||||
const bStartsWith = b.name.startsWith(normalizedQuery);
|
||||
if (aStartsWith !== bStartsWith) {
|
||||
return aStartsWith ? -1 : 1;
|
||||
}
|
||||
return a.index - b.index;
|
||||
})
|
||||
.slice(0, MAX_SKILL_SUGGESTIONS)
|
||||
.map(({ skill }) => skill);
|
||||
}
|
||||
|
||||
function getResolvedMode(
|
||||
mode: InputMode | undefined,
|
||||
supportsThinking: boolean,
|
||||
@ -182,6 +141,7 @@ export function InputBox({
|
||||
initialValue,
|
||||
onContextChange,
|
||||
onFollowupsVisibilityChange,
|
||||
onGoalChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
...props
|
||||
@ -215,6 +175,7 @@ export function InputBox({
|
||||
},
|
||||
) => void;
|
||||
onFollowupsVisibilityChange?: (visible: boolean) => void;
|
||||
onGoalChange?: (goal: GoalState | null) => void;
|
||||
onSubmit?: (message: PromptInputMessage) => void | Promise<void>;
|
||||
onStop?: () => void;
|
||||
}) {
|
||||
@ -230,6 +191,7 @@ export function InputBox({
|
||||
const { data: uploadLimits } = useUploadLimits(threadId);
|
||||
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const goalRequestStateRef = useRef(createGoalRequestState());
|
||||
const promptHistoryIndexRef = useRef<number | null>(null);
|
||||
const promptHistoryDraftRef = useRef("");
|
||||
|
||||
@ -251,6 +213,16 @@ export function InputBox({
|
||||
const [pendingSuggestion, setPendingSuggestion] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const builtinSlashCommands = useMemo<SlashSuggestion[]>(
|
||||
() => [
|
||||
{
|
||||
name: "goal",
|
||||
description: t.inputBox.goalCommandDescription,
|
||||
kind: "builtin",
|
||||
},
|
||||
],
|
||||
[t.inputBox.goalCommandDescription],
|
||||
);
|
||||
|
||||
const reportUploadLimitViolations = useCallback(
|
||||
(violations: UploadLimitViolation[]) => {
|
||||
@ -382,6 +354,11 @@ export function InputBox({
|
||||
promptHistoryDraftRef.current = "";
|
||||
}, [threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
const goalRequestState = goalRequestStateRef.current;
|
||||
return () => abortGoalRequest(goalRequestState);
|
||||
}, [threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentIndex = promptHistoryIndexRef.current;
|
||||
if (currentIndex !== null && currentIndex >= promptHistory.length) {
|
||||
@ -435,20 +412,131 @@ export function InputBox({
|
||||
[onContextChange, context],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
const handleGoalCommand = useCallback(
|
||||
async (command: GoalCommand): Promise<boolean> => {
|
||||
const request = beginGoalRequest(goalRequestStateRef.current, threadId);
|
||||
const signal = request.controller.signal;
|
||||
try {
|
||||
let goal: GoalState | null = null;
|
||||
if (command.kind === "status") {
|
||||
const response = await fetch(
|
||||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(
|
||||
threadId,
|
||||
)}/goal`,
|
||||
{ method: "GET", signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(await readGoalResponseError(response));
|
||||
}
|
||||
goal =
|
||||
((await response.json()) as { goal?: GoalState | null }).goal ??
|
||||
null;
|
||||
if (
|
||||
!isCurrentGoalRequest(
|
||||
goalRequestStateRef.current,
|
||||
request,
|
||||
threadId,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const objective = goal?.objective;
|
||||
toast.info(
|
||||
objective !== undefined
|
||||
? // Function replacer so a goal containing `$&`/`$1` isn't
|
||||
// interpreted as a replacement pattern.
|
||||
t.inputBox.goalActive.replace("{goal}", () => objective)
|
||||
: t.inputBox.goalNone,
|
||||
);
|
||||
onGoalChange?.(goal);
|
||||
} else if (command.kind === "clear") {
|
||||
const response = await fetch(
|
||||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(
|
||||
threadId,
|
||||
)}/goal`,
|
||||
{ method: "DELETE", signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(await readGoalResponseError(response));
|
||||
}
|
||||
if (
|
||||
!isCurrentGoalRequest(
|
||||
goalRequestStateRef.current,
|
||||
request,
|
||||
threadId,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
toast.success(t.inputBox.goalCleared);
|
||||
onGoalChange?.(null);
|
||||
} else {
|
||||
const response = await fetch(
|
||||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(
|
||||
threadId,
|
||||
)}/goal`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ objective: command.objective }),
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(await readGoalResponseError(response));
|
||||
}
|
||||
goal =
|
||||
((await response.json()) as { goal?: GoalState | null }).goal ??
|
||||
null;
|
||||
if (
|
||||
!isCurrentGoalRequest(
|
||||
goalRequestStateRef.current,
|
||||
request,
|
||||
threadId,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
toast.success(t.inputBox.goalSet);
|
||||
onGoalChange?.(goal);
|
||||
}
|
||||
textInput.setInput("");
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (
|
||||
isAbortError(error) ||
|
||||
!isCurrentGoalRequest(goalRequestStateRef.current, request, threadId)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t.inputBox.goalFailed,
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
finishGoalRequest(goalRequestStateRef.current, request);
|
||||
}
|
||||
},
|
||||
[
|
||||
onGoalChange,
|
||||
t.inputBox.goalActive,
|
||||
t.inputBox.goalCleared,
|
||||
t.inputBox.goalFailed,
|
||||
t.inputBox.goalNone,
|
||||
t.inputBox.goalSet,
|
||||
textInput,
|
||||
threadId,
|
||||
],
|
||||
);
|
||||
|
||||
const submitThreadMessage = useCallback(
|
||||
(message: PromptInputMessage) => {
|
||||
if (status === "streaming") {
|
||||
onStop?.();
|
||||
return;
|
||||
}
|
||||
if (!message.text.trim() && message.files.length === 0) {
|
||||
return;
|
||||
}
|
||||
const files = message.files.flatMap((file) =>
|
||||
file.file instanceof File ? [file.file] : [],
|
||||
);
|
||||
const uploadValidation = validateUploadLimits([], files, uploadLimits);
|
||||
if (uploadValidation.violations.length > 0) {
|
||||
reportUploadLimitViolations(uploadValidation.violations);
|
||||
return Promise.reject(new Error("Attachment limits exceeded."));
|
||||
}
|
||||
const placeholder = findSuggestionTemplatePlaceholder(message.text);
|
||||
@ -496,15 +584,54 @@ export function InputBox({
|
||||
context,
|
||||
onContextChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
reportUploadLimitViolations,
|
||||
resolvedModelName,
|
||||
selectedModel?.supports_thinking,
|
||||
status,
|
||||
t.inputBox.suggestionPlaceholderRequired,
|
||||
uploadLimits,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (message: PromptInputMessage) => {
|
||||
const submitAction = getInputSubmitAction({
|
||||
text: message.text,
|
||||
fileCount: message.files.length,
|
||||
status,
|
||||
});
|
||||
if (submitAction.kind === "goal") {
|
||||
promptHistoryIndexRef.current = null;
|
||||
promptHistoryDraftRef.current = "";
|
||||
setFollowups([]);
|
||||
setFollowupsHidden(false);
|
||||
setFollowupsLoading(false);
|
||||
const saved = await handleGoalCommand(submitAction.command);
|
||||
// Only start a run when a goal was actually saved; status/clear never run.
|
||||
if (
|
||||
saved &&
|
||||
submitAction.command.kind === "set" &&
|
||||
status !== "streaming"
|
||||
) {
|
||||
return submitThreadMessage({
|
||||
...message,
|
||||
text: submitAction.command.objective,
|
||||
files: [],
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (submitAction.kind === "stop") {
|
||||
onStop?.();
|
||||
return;
|
||||
}
|
||||
if (submitAction.kind === "empty") {
|
||||
return;
|
||||
}
|
||||
return submitThreadMessage(message);
|
||||
},
|
||||
[handleGoalCommand, onStop, status, submitThreadMessage],
|
||||
);
|
||||
|
||||
const requestFormSubmit = useCallback(() => {
|
||||
const form = promptRootRef.current?.querySelector("form");
|
||||
form?.requestSubmit();
|
||||
@ -564,8 +691,12 @@ export function InputBox({
|
||||
() =>
|
||||
slashSkillQuery === null
|
||||
? []
|
||||
: getMatchingSkillSuggestions(skills, slashSkillQuery),
|
||||
[skills, slashSkillQuery],
|
||||
: getMatchingSkillSuggestions(
|
||||
skills,
|
||||
slashSkillQuery,
|
||||
builtinSlashCommands,
|
||||
),
|
||||
[builtinSlashCommands, skills, slashSkillQuery],
|
||||
);
|
||||
const showSkillSuggestions =
|
||||
!disabled &&
|
||||
@ -579,8 +710,8 @@ export function InputBox({
|
||||
}, [slashSkillQuery, skillSuggestions.length]);
|
||||
|
||||
const applySkillSuggestion = useCallback(
|
||||
(skill: Skill) => {
|
||||
const nextValue = `/${skill.name} `;
|
||||
(suggestion: SlashSuggestion) => {
|
||||
const nextValue = `/${suggestion.name} `;
|
||||
textInput.setInput(nextValue);
|
||||
setDismissedSkillSuggestionValue(nextValue);
|
||||
requestAnimationFrame(() => {
|
||||
@ -885,7 +1016,7 @@ export function InputBox({
|
||||
className="bg-popover/95 text-popover-foreground border-border max-h-72 overflow-y-auto rounded-xl border p-1 shadow-lg backdrop-blur-sm"
|
||||
role="listbox"
|
||||
>
|
||||
{skillSuggestions.map((skill, index) => {
|
||||
{skillSuggestions.map((suggestion, index) => {
|
||||
const selected = index === skillSuggestionIndex;
|
||||
return (
|
||||
<button
|
||||
@ -896,21 +1027,25 @@ export function InputBox({
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-popover-foreground hover:bg-accent/70 hover:text-accent-foreground",
|
||||
)}
|
||||
key={skill.name}
|
||||
onClick={() => applySkillSuggestion(skill)}
|
||||
key={`${suggestion.kind}:${suggestion.name}`}
|
||||
onClick={() => applySkillSuggestion(suggestion)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onMouseEnter={() => setSkillSuggestionIndex(index)}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<SparklesIcon className="text-muted-foreground size-4 shrink-0" />
|
||||
{suggestion.kind === "builtin" ? (
|
||||
<TargetIcon className="text-muted-foreground size-4 shrink-0" />
|
||||
) : (
|
||||
<SparklesIcon className="text-muted-foreground size-4 shrink-0" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
/{skill.name}
|
||||
/{suggestion.name}
|
||||
</span>
|
||||
{skill.description && (
|
||||
{suggestion.description && (
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{skill.description}
|
||||
{suggestion.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
68
frontend/src/components/workspace/use-active-goal.ts
Normal file
68
frontend/src/components/workspace/use-active-goal.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { GoalState } from "@/core/threads/types";
|
||||
|
||||
import { goalReconciliationKey } from "./goal-status-helpers";
|
||||
|
||||
export type UseActiveGoalResult = {
|
||||
/** The goal to render — the optimistic override while set, else server state. */
|
||||
activeGoal: GoalState | null;
|
||||
hasGoal: boolean;
|
||||
/** Apply an optimistic goal after a `/goal` command (or `null` to hide it). */
|
||||
setLocalGoal: (goal: GoalState | null) => void;
|
||||
};
|
||||
|
||||
export function resolveActiveGoal(
|
||||
localGoal: GoalState | null | undefined,
|
||||
serverGoal: GoalState | null | undefined,
|
||||
): GoalState | null {
|
||||
return localGoal !== undefined ? localGoal : (serverGoal ?? null);
|
||||
}
|
||||
|
||||
export function shouldResetLocalGoalOverride({
|
||||
serverGoalProvided,
|
||||
threadChanged,
|
||||
}: {
|
||||
serverGoalProvided: boolean;
|
||||
threadChanged: boolean;
|
||||
}): boolean {
|
||||
if (threadChanged) {
|
||||
return true;
|
||||
}
|
||||
return serverGoalProvided;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles the optimistic `/goal`-command result with the server's goal state.
|
||||
*
|
||||
* A `/goal` command updates the UI immediately via `setLocalGoal`, but that
|
||||
* override is dropped as soon as the server explicitly reports goal state —
|
||||
* switching threads, a new `continuation_count`, or a cleared goal. A stream
|
||||
* chunk that omits the `goal` field is not treated as a clear, because
|
||||
* clarification interrupts can publish partial values while the active goal is
|
||||
* still present in the checkpoint.
|
||||
*/
|
||||
export function useActiveGoal(
|
||||
threadId: string,
|
||||
serverGoal: GoalState | null | undefined,
|
||||
): UseActiveGoalResult {
|
||||
const [localGoal, setLocalGoal] = useState<GoalState | null | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const previousThreadIdRef = useRef(threadId);
|
||||
const serverGoalProvided = serverGoal !== undefined;
|
||||
const serverGoalKey = serverGoalProvided
|
||||
? goalReconciliationKey(serverGoal)
|
||||
: "missing";
|
||||
|
||||
useEffect(() => {
|
||||
const threadChanged = previousThreadIdRef.current !== threadId;
|
||||
previousThreadIdRef.current = threadId;
|
||||
if (shouldResetLocalGoalOverride({ serverGoalProvided, threadChanged })) {
|
||||
setLocalGoal(undefined);
|
||||
}
|
||||
}, [serverGoalKey, serverGoalProvided, threadId]);
|
||||
|
||||
const activeGoal = resolveActiveGoal(localGoal, serverGoal);
|
||||
return { activeGoal, hasGoal: Boolean(activeGoal), setLocalGoal };
|
||||
}
|
||||
@ -118,6 +118,16 @@ export const enUS: Translations = {
|
||||
followupConfirmReplace: "Replace & send",
|
||||
suggestionPlaceholderRequired:
|
||||
"Replace the suggestion placeholder before sending.",
|
||||
goalCommandDescription: "Set, show, or clear an active goal",
|
||||
goalLabel: "Goal",
|
||||
goalContinuing: "Continuing {count}/{max}",
|
||||
goalContinuationTooltip:
|
||||
"Auto-continued {count}/{max} times toward the goal; stops at the limit.",
|
||||
goalSet: "Goal set.",
|
||||
goalCleared: "Goal cleared.",
|
||||
goalNone: "No active goal.",
|
||||
goalActive: "Active goal: {goal}",
|
||||
goalFailed: "Goal command failed.",
|
||||
suggestions: [
|
||||
{
|
||||
suggestion: "Write",
|
||||
|
||||
@ -95,6 +95,15 @@ export interface Translations {
|
||||
followupConfirmAppend: string;
|
||||
followupConfirmReplace: string;
|
||||
suggestionPlaceholderRequired: string;
|
||||
goalCommandDescription: string;
|
||||
goalLabel: string;
|
||||
goalContinuing: string;
|
||||
goalContinuationTooltip: string;
|
||||
goalSet: string;
|
||||
goalCleared: string;
|
||||
goalNone: string;
|
||||
goalActive: string;
|
||||
goalFailed: string;
|
||||
suggestions: {
|
||||
suggestion: string;
|
||||
prompt: string;
|
||||
|
||||
@ -112,6 +112,16 @@ export const zhCN: Translations = {
|
||||
followupConfirmAppend: "追加并发送",
|
||||
followupConfirmReplace: "替换并发送",
|
||||
suggestionPlaceholderRequired: "发送前请先填写建议模板中的占位内容。",
|
||||
goalCommandDescription: "设置、查看或清除当前目标",
|
||||
goalLabel: "目标",
|
||||
goalContinuing: "续跑中 {count}/{max}",
|
||||
goalContinuationTooltip:
|
||||
"为达成目标已自动续跑 {count}/{max} 次,达上限后自动停止",
|
||||
goalSet: "目标已设置。",
|
||||
goalCleared: "目标已清除。",
|
||||
goalNone: "当前没有目标。",
|
||||
goalActive: "当前目标:{goal}",
|
||||
goalFailed: "目标命令执行失败。",
|
||||
suggestions: [
|
||||
{
|
||||
suggestion: "写作",
|
||||
|
||||
@ -2,11 +2,39 @@ import type { Message, Thread } from "@langchain/langgraph-sdk";
|
||||
|
||||
import type { Todo } from "../todos";
|
||||
|
||||
export interface GoalState {
|
||||
objective: string;
|
||||
status: "active";
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
continuation_count: number;
|
||||
max_continuations: number;
|
||||
no_progress_count: number;
|
||||
max_no_progress_continuations: number;
|
||||
last_evaluation?: {
|
||||
satisfied: boolean;
|
||||
blocker:
|
||||
| "none"
|
||||
| "missing_evidence"
|
||||
| "needs_user_input"
|
||||
| "run_failed"
|
||||
| "external_wait"
|
||||
| "goal_not_met_yet";
|
||||
reason: string;
|
||||
evidence_summary?: string;
|
||||
run_id?: string;
|
||||
evaluated_at?: string;
|
||||
progress_key?: string;
|
||||
stand_down_reason?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgentThreadState extends Record<string, unknown> {
|
||||
title: string;
|
||||
messages: Message[];
|
||||
artifacts?: string[];
|
||||
todos?: Todo[];
|
||||
goal?: GoalState | null;
|
||||
}
|
||||
|
||||
export interface AgentThreadContext extends Record<string, unknown> {
|
||||
|
||||
@ -44,6 +44,73 @@ test.describe("Chat workspace", () => {
|
||||
await expect(textarea).toHaveValue("/data-analysis ");
|
||||
});
|
||||
|
||||
test("goal command sets a goal and starts an agent run", async ({ page }) => {
|
||||
let streamCalls = 0;
|
||||
await page.goto("/workspace/chats/new");
|
||||
await page.route("**/runs/stream", (route) => {
|
||||
streamCalls += 1;
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
const textarea = page.getByPlaceholder(/how can i assist you/i);
|
||||
await expect(textarea).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await textarea.fill("/go");
|
||||
await expect(page.getByRole("option", { name: /goal/i })).toBeVisible();
|
||||
|
||||
await textarea.fill("/goal finish all tests");
|
||||
await textarea.press("Enter");
|
||||
|
||||
await expect(
|
||||
page.locator("span.font-medium", { hasText: "finish all tests" }),
|
||||
).toBeVisible();
|
||||
await expect.poll(() => streamCalls).toBe(1);
|
||||
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible();
|
||||
});
|
||||
|
||||
test("goal command keeps the welcome header clear of the goal status", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/workspace/chats/new");
|
||||
|
||||
const textarea = page.getByPlaceholder(/how can i assist you/i);
|
||||
await expect(textarea).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await textarea.fill(
|
||||
"/goal finish a small repo check and report the result",
|
||||
);
|
||||
await textarea.press("Enter");
|
||||
|
||||
const goal = page.locator("span.font-medium", {
|
||||
hasText: "finish a small repo check",
|
||||
});
|
||||
await expect(goal).toBeVisible();
|
||||
await expect(page.getByText(/welcome to/i)).toBeHidden();
|
||||
|
||||
const overlaps = await page.evaluate(() => {
|
||||
const welcome = [...document.querySelectorAll("p")].find((el) =>
|
||||
el.textContent?.toLowerCase().includes("welcome to"),
|
||||
);
|
||||
const goal = [...document.querySelectorAll("span")].find((el) =>
|
||||
el.textContent?.includes(
|
||||
"finish a small repo check and report the result",
|
||||
),
|
||||
);
|
||||
if (!welcome || !goal) {
|
||||
return false;
|
||||
}
|
||||
const welcomeRect = welcome.getBoundingClientRect();
|
||||
const goalRect = goal.getBoundingClientRect();
|
||||
return !(
|
||||
welcomeRect.right < goalRect.left ||
|
||||
goalRect.right < welcomeRect.left ||
|
||||
welcomeRect.bottom < goalRect.top ||
|
||||
goalRect.bottom < welcomeRect.top
|
||||
);
|
||||
});
|
||||
expect(overlaps).toBe(false);
|
||||
});
|
||||
|
||||
test("uses arrow keys to navigate skill suggestions before prompt history", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@ -35,6 +35,7 @@ export type MockThread = {
|
||||
metadata?: Record<string, unknown>;
|
||||
messages?: unknown[];
|
||||
artifacts?: string[];
|
||||
goal?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type MockAgent = {
|
||||
@ -133,7 +134,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
...(thread.agent_name ? { agent_name: thread.agent_name } : {}),
|
||||
},
|
||||
status: "idle",
|
||||
values: { title: thread.title ?? "Untitled" },
|
||||
values: { title: thread.title ?? "Untitled", goal: thread.goal ?? null },
|
||||
});
|
||||
|
||||
// Auth — keep workspace tests independent from a real gateway session.
|
||||
@ -293,6 +294,63 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
void page.route(/\/api\/threads\/[^/]+\/goal$/, async (route) => {
|
||||
const threadId = decodeURIComponent(
|
||||
new URL(route.request().url()).pathname.split("/").at(-2) ?? "",
|
||||
);
|
||||
let matchingThread = threads.find(
|
||||
(thread) => thread.thread_id === threadId,
|
||||
);
|
||||
|
||||
if (route.request().method() === "GET") {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ goal: matchingThread?.goal ?? null }),
|
||||
});
|
||||
}
|
||||
|
||||
if (route.request().method() === "DELETE") {
|
||||
if (matchingThread) {
|
||||
matchingThread.goal = null;
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ goal: null }),
|
||||
});
|
||||
}
|
||||
|
||||
if (route.request().method() === "PUT") {
|
||||
const payload = route.request().postDataJSON() as {
|
||||
objective?: string;
|
||||
};
|
||||
const goal = {
|
||||
objective: payload.objective ?? "",
|
||||
status: "active",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
continuation_count: 0,
|
||||
max_continuations: 8,
|
||||
no_progress_count: 0,
|
||||
max_no_progress_continuations: 2,
|
||||
};
|
||||
matchingThread ??= {
|
||||
thread_id: threadId,
|
||||
title: "New Chat",
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
upsertThread({ ...matchingThread, goal });
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ goal }),
|
||||
});
|
||||
}
|
||||
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
void page.route("**/api/threads/*/uploads/limits", (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
return route.fulfill({
|
||||
@ -318,6 +376,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
{
|
||||
values: {
|
||||
title: matchingThread.title ?? "Untitled",
|
||||
goal: matchingThread.goal ?? null,
|
||||
messages: matchingThread.messages ?? [
|
||||
{
|
||||
type: "human",
|
||||
@ -360,6 +419,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
body: JSON.stringify({
|
||||
values: {
|
||||
title: matchingThread?.title ?? "Untitled",
|
||||
goal: matchingThread?.goal ?? null,
|
||||
messages: matchingThread
|
||||
? (matchingThread.messages ?? [
|
||||
{
|
||||
@ -445,13 +505,20 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
|
||||
// Run stream — returns a minimal SSE response with an AI message
|
||||
const handleMockRunStream = (route: Route) => {
|
||||
const requestUrl = route.request().url();
|
||||
const matchingThread = threads.find((thread) =>
|
||||
requestUrl.includes(encodeURIComponent(thread.thread_id)),
|
||||
);
|
||||
const fallbackGoal = threads.find((thread) => thread.goal)?.goal ?? null;
|
||||
const goal = matchingThread?.goal ?? fallbackGoal;
|
||||
upsertThread({
|
||||
thread_id: MOCK_THREAD_ID,
|
||||
title: "New Chat",
|
||||
updated_at: new Date().toISOString(),
|
||||
goal,
|
||||
messages: mockStreamMessages(),
|
||||
});
|
||||
return handleRunStream(route);
|
||||
return handleRunStream(route, { goal });
|
||||
};
|
||||
|
||||
void page.route("**/api/langgraph/runs/stream", handleMockRunStream);
|
||||
@ -554,7 +621,10 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
* Build a minimal SSE stream that the LangGraph SDK can parse.
|
||||
* The stream returns a single AI message: "Hello from DeerFlow!".
|
||||
*/
|
||||
export function handleRunStream(route: Route) {
|
||||
export function handleRunStream(
|
||||
route: Route,
|
||||
values: Record<string, unknown> = {},
|
||||
) {
|
||||
const events = [
|
||||
{
|
||||
event: "metadata",
|
||||
@ -563,6 +633,7 @@ export function handleRunStream(route: Route) {
|
||||
{
|
||||
event: "values",
|
||||
data: {
|
||||
...values,
|
||||
messages: mockStreamMessages(),
|
||||
},
|
||||
},
|
||||
|
||||
@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from "@rstest/core";
|
||||
|
||||
import {
|
||||
getGoalContinuationDisplay,
|
||||
goalReconciliationKey,
|
||||
} from "@/components/workspace/goal-status-helpers";
|
||||
import type { GoalState } from "@/core/threads/types";
|
||||
|
||||
function makeGoal(overrides: Partial<GoalState> = {}): GoalState {
|
||||
return {
|
||||
objective: "ship it",
|
||||
status: "active",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
continuation_count: 0,
|
||||
max_continuations: 8,
|
||||
no_progress_count: 0,
|
||||
max_no_progress_continuations: 2,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("getGoalContinuationDisplay", () => {
|
||||
it("hides the counter before the agent has auto-continued", () => {
|
||||
expect(
|
||||
getGoalContinuationDisplay({
|
||||
continuation_count: 0,
|
||||
max_continuations: 8,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("shows count and max once continuation has started", () => {
|
||||
expect(
|
||||
getGoalContinuationDisplay({
|
||||
continuation_count: 1,
|
||||
max_continuations: 8,
|
||||
}),
|
||||
).toEqual({ count: 1, max: 8 });
|
||||
expect(
|
||||
getGoalContinuationDisplay({
|
||||
continuation_count: 8,
|
||||
max_continuations: 8,
|
||||
}),
|
||||
).toEqual({ count: 8, max: 8 });
|
||||
});
|
||||
|
||||
it("treats missing or negative counts as hidden", () => {
|
||||
expect(
|
||||
getGoalContinuationDisplay({
|
||||
continuation_count: -1,
|
||||
max_continuations: 8,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getGoalContinuationDisplay({
|
||||
continuation_count: undefined as unknown as number,
|
||||
max_continuations: undefined as unknown as number,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("goalReconciliationKey", () => {
|
||||
it("returns a constant sentinel when there is no goal", () => {
|
||||
expect(goalReconciliationKey(null)).toBe("none");
|
||||
});
|
||||
|
||||
it("is stable for an unchanged goal", () => {
|
||||
expect(goalReconciliationKey(makeGoal())).toBe(
|
||||
goalReconciliationKey(makeGoal()),
|
||||
);
|
||||
});
|
||||
|
||||
it("changes when the agent auto-continues", () => {
|
||||
expect(goalReconciliationKey(makeGoal({ continuation_count: 0 }))).not.toBe(
|
||||
goalReconciliationKey(
|
||||
makeGoal({
|
||||
continuation_count: 1,
|
||||
updated_at: "2026-01-01T00:01:00Z",
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("changes when a different goal is set", () => {
|
||||
expect(goalReconciliationKey(makeGoal({ objective: "a" }))).not.toBe(
|
||||
goalReconciliationKey(makeGoal({ objective: "b" })),
|
||||
);
|
||||
});
|
||||
|
||||
it("distinguishes a cleared goal from an active one", () => {
|
||||
expect(goalReconciliationKey(null)).not.toBe(
|
||||
goalReconciliationKey(makeGoal()),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,260 @@
|
||||
import { describe, expect, it } from "@rstest/core";
|
||||
|
||||
import {
|
||||
abortGoalRequest,
|
||||
beginGoalRequest,
|
||||
createGoalRequestState,
|
||||
findSuggestionTemplatePlaceholder,
|
||||
finishGoalRequest,
|
||||
getInputSubmitAction,
|
||||
getLeadingSlashSkillQuery,
|
||||
getMatchingSkillSuggestions,
|
||||
isAbortError,
|
||||
isCurrentGoalRequest,
|
||||
parseGoalCommand,
|
||||
readGoalResponseError,
|
||||
type SlashSuggestion,
|
||||
} from "@/components/workspace/input-box-helpers";
|
||||
import type { Skill } from "@/core/skills";
|
||||
|
||||
function makeSkill(name: string, enabled = true): Skill {
|
||||
return {
|
||||
name,
|
||||
description: `${name} description`,
|
||||
enabled,
|
||||
} as Skill;
|
||||
}
|
||||
|
||||
// Builtin command names are bare (no leading slash); the composer renders them
|
||||
// as `/${name}`. Mirror that shape here.
|
||||
const builtins: SlashSuggestion[] = [
|
||||
{
|
||||
name: "goal",
|
||||
description: "Set, show, or clear an active goal",
|
||||
kind: "builtin",
|
||||
},
|
||||
{ name: "new", description: "Start a new thread", kind: "builtin" },
|
||||
];
|
||||
|
||||
describe("parseGoalCommand", () => {
|
||||
it("returns status for a bare /goal", () => {
|
||||
expect(parseGoalCommand("/goal")).toEqual({ kind: "status" });
|
||||
expect(parseGoalCommand(" /goal ")).toEqual({ kind: "status" });
|
||||
});
|
||||
|
||||
it("treats clear/reset/off as clear (case-insensitive)", () => {
|
||||
expect(parseGoalCommand("/goal clear")).toEqual({ kind: "clear" });
|
||||
expect(parseGoalCommand("/GOAL Reset")).toEqual({ kind: "clear" });
|
||||
expect(parseGoalCommand("/goal off")).toEqual({ kind: "clear" });
|
||||
});
|
||||
|
||||
it("captures the objective for /goal <text>", () => {
|
||||
expect(parseGoalCommand("/goal ship the feature")).toEqual({
|
||||
kind: "set",
|
||||
objective: "ship the feature",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when the input is not a /goal command", () => {
|
||||
expect(parseGoalCommand("/goalkeeper do thing")).toBeNull();
|
||||
expect(parseGoalCommand("hello")).toBeNull();
|
||||
expect(parseGoalCommand("/new")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getInputSubmitAction", () => {
|
||||
it("handles /goal commands before the streaming stop shortcut", () => {
|
||||
expect(
|
||||
getInputSubmitAction({
|
||||
text: "/goal ",
|
||||
fileCount: 0,
|
||||
status: "streaming",
|
||||
}),
|
||||
).toEqual({ kind: "goal", command: { kind: "status" } });
|
||||
});
|
||||
|
||||
it("handles /goal set commands before the streaming stop shortcut", () => {
|
||||
expect(
|
||||
getInputSubmitAction({
|
||||
text: "/goal finish the work",
|
||||
fileCount: 0,
|
||||
status: "streaming",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "goal",
|
||||
command: { kind: "set", objective: "finish the work" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps ordinary streaming submits as stop", () => {
|
||||
expect(
|
||||
getInputSubmitAction({
|
||||
text: "hello",
|
||||
fileCount: 0,
|
||||
status: "streaming",
|
||||
}),
|
||||
).toEqual({ kind: "stop" });
|
||||
});
|
||||
|
||||
it("does not treat /goal text with attachments as a goal command", () => {
|
||||
expect(
|
||||
getInputSubmitAction({
|
||||
text: "/goal ",
|
||||
fileCount: 1,
|
||||
status: "ready",
|
||||
}),
|
||||
).toEqual({ kind: "message" });
|
||||
});
|
||||
|
||||
it("ignores empty ready submits", () => {
|
||||
expect(
|
||||
getInputSubmitAction({
|
||||
text: " ",
|
||||
fileCount: 0,
|
||||
status: "ready",
|
||||
}),
|
||||
).toEqual({ kind: "empty" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLeadingSlashSkillQuery", () => {
|
||||
it("returns the query for a leading slash token", () => {
|
||||
expect(getLeadingSlashSkillQuery("/rev")).toBe("rev");
|
||||
expect(getLeadingSlashSkillQuery("/")).toBe("");
|
||||
});
|
||||
|
||||
it("returns null when there is no leading slash or the token is not bare", () => {
|
||||
expect(getLeadingSlashSkillQuery("rev")).toBeNull();
|
||||
expect(getLeadingSlashSkillQuery("/rev now")).toBeNull();
|
||||
expect(getLeadingSlashSkillQuery("/a/b")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMatchingSkillSuggestions", () => {
|
||||
it("excludes disabled skills and ranks prefix matches first", () => {
|
||||
const skills = [
|
||||
makeSkill("deep-research"),
|
||||
makeSkill("review"),
|
||||
makeSkill("reviewer-disabled", false),
|
||||
];
|
||||
|
||||
const result = getMatchingSkillSuggestions(skills, "rev", []);
|
||||
|
||||
expect(result.map((s) => s.name)).toEqual(["review"]);
|
||||
expect(result.every((s) => s.kind === "skill")).toBe(true);
|
||||
});
|
||||
|
||||
it("includes matching builtin commands after skills", () => {
|
||||
const result = getMatchingSkillSuggestions(
|
||||
[makeSkill("goal-helper")],
|
||||
"goal",
|
||||
builtins,
|
||||
);
|
||||
|
||||
expect(result.map((s) => s.name)).toContain("goal-helper");
|
||||
expect(result.map((s) => s.name)).toContain("goal");
|
||||
});
|
||||
|
||||
it("excludes skills that collide with builtin command names", () => {
|
||||
const result = getMatchingSkillSuggestions(
|
||||
[makeSkill("goal"), makeSkill("goal-helper")],
|
||||
"goal",
|
||||
builtins,
|
||||
);
|
||||
|
||||
expect(result.map((s) => `${s.kind}:${s.name}`)).toEqual([
|
||||
"skill:goal-helper",
|
||||
"builtin:goal",
|
||||
]);
|
||||
});
|
||||
|
||||
it("caps the number of suggestions", () => {
|
||||
const skills = Array.from({ length: 10 }, (_, i) =>
|
||||
makeSkill(`skill-${i}`),
|
||||
);
|
||||
const result = getMatchingSkillSuggestions(skills, "", []);
|
||||
expect(result.length).toBeLessThanOrEqual(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readGoalResponseError", () => {
|
||||
it("returns the detail string when present", async () => {
|
||||
const response = {
|
||||
status: 422,
|
||||
json: async () => ({ detail: "Goal objective must not be empty." }),
|
||||
} as unknown as Response;
|
||||
expect(await readGoalResponseError(response)).toBe(
|
||||
"Goal objective must not be empty.",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the HTTP status when detail is missing or unparseable", async () => {
|
||||
const noDetail = {
|
||||
status: 500,
|
||||
json: async () => ({}),
|
||||
} as unknown as Response;
|
||||
expect(await readGoalResponseError(noDetail)).toBe("HTTP 500");
|
||||
|
||||
const broken = {
|
||||
status: 503,
|
||||
json: async () => {
|
||||
throw new Error("not json");
|
||||
},
|
||||
} as unknown as Response;
|
||||
expect(await readGoalResponseError(broken)).toBe("HTTP 503");
|
||||
});
|
||||
});
|
||||
|
||||
describe("goal request lifecycle", () => {
|
||||
it("aborts a pending goal request when the thread changes and blocks stale updates", () => {
|
||||
const state = createGoalRequestState();
|
||||
const first = beginGoalRequest(state, "thread-1");
|
||||
const updates: string[] = [];
|
||||
|
||||
abortGoalRequest(state);
|
||||
const second = beginGoalRequest(state, "thread-2");
|
||||
|
||||
if (isCurrentGoalRequest(state, first, "thread-1")) {
|
||||
updates.push("thread-1");
|
||||
}
|
||||
if (isCurrentGoalRequest(state, second, "thread-2")) {
|
||||
updates.push("thread-2");
|
||||
}
|
||||
|
||||
expect(first.controller.signal.aborted).toBe(true);
|
||||
expect(second.controller.signal.aborted).toBe(false);
|
||||
expect(updates).toEqual(["thread-2"]);
|
||||
});
|
||||
|
||||
it("does not let an older request finish a newer one", () => {
|
||||
const state = createGoalRequestState();
|
||||
const first = beginGoalRequest(state, "thread-1");
|
||||
const second = beginGoalRequest(state, "thread-1");
|
||||
|
||||
finishGoalRequest(state, first);
|
||||
|
||||
expect(isCurrentGoalRequest(state, second, "thread-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes abort-shaped errors", () => {
|
||||
expect(isAbortError(new DOMException("aborted", "AbortError"))).toBe(true);
|
||||
expect(
|
||||
isAbortError(Object.assign(new Error("aborted"), { name: "AbortError" })),
|
||||
).toBe(true);
|
||||
expect(isAbortError(new Error("other"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findSuggestionTemplatePlaceholder", () => {
|
||||
it("locates a topic/source placeholder", () => {
|
||||
const found = findSuggestionTemplatePlaceholder("Research [topic] deeply");
|
||||
expect(found).not.toBeNull();
|
||||
expect(
|
||||
found && "Research [topic] deeply".slice(found.start, found.end),
|
||||
).toBe("[topic]");
|
||||
});
|
||||
|
||||
it("returns null when no placeholder is present", () => {
|
||||
expect(findSuggestionTemplatePlaceholder("no placeholder here")).toBeNull();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "@rstest/core";
|
||||
|
||||
import {
|
||||
resolveActiveGoal,
|
||||
shouldResetLocalGoalOverride,
|
||||
} from "@/components/workspace/use-active-goal";
|
||||
import type { GoalState } from "@/core/threads/types";
|
||||
|
||||
function makeGoal(overrides: Partial<GoalState> = {}): GoalState {
|
||||
return {
|
||||
objective: "ship the landing page",
|
||||
status: "active",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
continuation_count: 0,
|
||||
max_continuations: 8,
|
||||
no_progress_count: 0,
|
||||
max_no_progress_continuations: 2,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveActiveGoal", () => {
|
||||
it("keeps the optimistic goal when stream values omit the goal field", () => {
|
||||
const localGoal = makeGoal();
|
||||
|
||||
expect(resolveActiveGoal(localGoal, undefined)).toBe(localGoal);
|
||||
});
|
||||
|
||||
it("falls back to null when neither local nor server state has a goal", () => {
|
||||
expect(resolveActiveGoal(undefined, undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldResetLocalGoalOverride", () => {
|
||||
it("does not reset an optimistic goal when the same thread omits goal from stream values", () => {
|
||||
expect(
|
||||
shouldResetLocalGoalOverride({
|
||||
serverGoalProvided: false,
|
||||
threadChanged: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("resets an optimistic goal when the server explicitly clears the goal", () => {
|
||||
expect(
|
||||
shouldResetLocalGoalOverride({
|
||||
serverGoalProvided: true,
|
||||
threadChanged: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("resets an optimistic goal on real thread navigation", () => {
|
||||
expect(
|
||||
shouldResetLocalGoalOverride({
|
||||
serverGoalProvided: false,
|
||||
threadChanged: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user