diff --git a/document/en/api/overview.md b/document/en/api/overview.md index 971419a8..50b11e92 100644 --- a/document/en/api/overview.md +++ b/document/en/api/overview.md @@ -109,9 +109,11 @@ The request body is a `ChatRequest` (`src/backend/api/schemas.py`): `chat_id` an | `run_started` | First frame of the stream | `run_id` (for resume/cancel), `message_id`, `chat_id` | | `thinking` | Reasoning phase | `message` (phase hint) or `delta` (incremental thinking text) | | `content` | Answer text delta | `event: "ai_message"`, `delta`, `chat_id` | -| `tool_call` | Agent invokes a tool | `tool_name`, `tool_display_name`, `tool_args`, `tool_id`, `subagent_name?` | +| `tool_call_start` | The model starts constructing a tool call | `tool_name`, `tool_display_name`, `tool_id` | +| `tool_call_delta` | Incremental tool-argument JSON (batched by the backend) | `tool_name`, `tool_id`, `arguments_delta` | +| `tool_call` | Arguments are complete and execution is about to start | `tool_name`, `tool_display_name`, `tool_args`, `tool_id`, `subagent_name?` | | `tool_result` | Tool returns | `tool_name`, `result` (JSON), `tool_id`, `citations` (citation items) | -| `tool_pending` | Model is buffering tool args / between call start and args | `reason` (e.g. `tool_call_start` / `llm_buffering`) | +| `tool_pending` | Waiting fallback when the provider exposes no parseable deltas | `reason` (e.g. `llm_buffering`) | | `file_confirm` | A tool is suspended awaiting user confirmation of a "My Space" write | `confirm_id`, `op`, `logical_path`, `message`, `expired`; the stream stays open — the user confirms out-of-band via `POST /v1/chats/{chat_id}/file-confirm` and the tool resumes | | `batch_confirm` | A batch-execution plan awaits user confirmation | `plan_id`, `total`, `preview`, `default_template`, `placeholder_keys`; confirm via `POST /v1/batch/{plan_id}/confirm` | | `meta` | Final wrap-up frame of an answer | `route`, `sources`, `artifacts`, `citations`, `warnings`, `is_markdown`, `message_id`, `workspace_files` | @@ -124,6 +126,12 @@ data: {"type": "run_started", "run_id": "run_9f8e7d", "message_id": "msg_001", " data: {"type": "thinking", "message": "Analyzing your question...", "chat_id": "chat_abc123"} +data: {"type": "tool_call_start", "tool_name": "internet_search", "tool_display_name": "Web Search", "tool_id": "call_01", "chat_id": "chat_abc123"} + +data: {"type": "tool_call_delta", "tool_name": "internet_search", "tool_id": "call_01", "arguments_delta": "{\"query\":\"Beijing ", "chat_id": "chat_abc123"} + +data: {"type": "tool_call_delta", "tool_name": "internet_search", "tool_id": "call_01", "arguments_delta": "weather today\"}", "chat_id": "chat_abc123"} + data: {"type": "tool_call", "tool_name": "internet_search", "tool_display_name": "Web Search", "tool_args": {"query": "Beijing weather today"}, "tool_id": "call_01", "chat_id": "chat_abc123"} data: {"type": "tool_result", "tool_name": "internet_search", "result": {"result": {"query": "Beijing weather today"}}, "tool_id": "call_01", "citations": [{"id": "internet_search-1", "title": "..."}], "chat_id": "chat_abc123"} diff --git a/document/en/architecture/frontend.md b/document/en/architecture/frontend.md index f6cf8e13..6219ddce 100644 --- a/document/en/architecture/frontend.md +++ b/document/en/architecture/frontend.md @@ -83,7 +83,7 @@ SSE streams bypass the JSON channel of `api.ts`; they are consumed directly from | Hook | Responsibility | |---|---| -| `useStreaming` | The main SSE consumer: exposes `send` / `abort` / `regenerate` / `editAndResend` / `resumeRunIfAny`; parses `content/thinking/tool_call/tool_result/tool_progress/meta/error` events, maintains text segments and the tool timeline, supports run resumption | +| `useStreaming` | The main SSE consumer: exposes `send` / `abort` / `regenerate` / `editAndResend` / `resumeRunIfAny`; parses `content/thinking/tool_call_start/tool_call_delta/tool_call/tool_result/tool_progress/meta/error` events, maintains text segments and the tool timeline, supports run resumption | | `useChatActions` | Session-management actions: create / delete / rename / pin & favorite / export / share / summary & classification | | `useChatInit` | Session initialization and active-run recovery on app start | | `usePlanMode` | Plan-mode SSE consumer (shared by first execution and resume) | @@ -121,7 +121,7 @@ useStreaming.send │ 2. fetch POST /v1/chats/stream (attachments carry only file_id/name/mime_type) │ parses data: {json} line by line │ ├─ content/thinking → appended into segments (utils/segments.ts) - │ ├─ tool_call/tool_result → tool timeline (components/tool/) + │ ├─ tool_call_start/tool_call_delta/tool_call/tool_result → update the tool timeline in place by tool_id (components/tool/) │ ├─ file_confirm/batch_confirm → confirm bar / modal (hard pause) │ └─ meta → citation sources and artifact list written onto the message ▼ diff --git a/document/en/architecture/overview.md b/document/en/architecture/overview.md index 1f055b48..38969e3c 100644 --- a/document/en/architecture/overview.md +++ b/document/en/architecture/overview.md @@ -174,14 +174,16 @@ Each event is `data: {json}\n\n` with a `type` discriminator; the stream termina |---|---|---| | `content` (`event: ai_message`) | Body text delta | `chats.py::_stream_sse_response` | | `thinking` | Extended-thinking delta | `core/chat/tool_log.py::build_thinking_event` | -| `tool_call` | Tool invocation started (name + args) | `core/chat/tool_log.py` | +| `tool_call_start` | Tool invocation starts (stable ID + name) | `core/chat/tool_log.py` | +| `tool_call_delta` | Batched incremental tool-argument JSON | `core/chat/tool_log.py` | +| `tool_call` | Tool arguments are complete and execution is about to start | `core/chat/tool_log.py` | | `tool_result` | Tool execution result (with citations, artifact card payloads) | `core/chat/tool_log.py` + `orchestration/tool_payloads.py` | | `tool_progress` | Progress reports from long-running tools | `chats.py` | | `batch_confirm` / `file_confirm` | Batch-execution confirmation; MySpace write confirmation (hard-pause gate) | `chats.py` | | `meta` | Trailing metadata: route, citation sources, artifact list, etc. | `orchestration/workflow.py` | | `error` | Error event (immediately followed by `[DONE]`) | `chats.py` | -The frontend's `src/frontend/src/hooks/useStreaming.ts` dispatches on `type` and renders text, the tool timeline, and citations incrementally into the message bubble. +The frontend's `src/frontend/src/hooks/useStreaming.ts` dispatches on `type` and merges the start, deltas, completed call, and result in place into one tool card keyed by `tool_id`. ## Container Topology diff --git a/document/en/modules/autonomous-loop.md b/document/en/modules/autonomous-loop.md index 6dba507b..0b844bb4 100644 --- a/document/en/modules/autonomous-loop.md +++ b/document/en/modules/autonomous-loop.md @@ -1,73 +1,77 @@ # Autonomous Loop -> Last updated: 2026-07-10 +> Last updated: 2026-08-13 -The Autonomous Loop upgrades the agent from single-turn Q&A to a **long-running task that self-advances across many calls, maintains external state, and stops autonomously on a verifiable goal**. Alongside regular chat (single-turn) and plan mode (linear multi-step), it provides a third run mode: a run-level self-driving loop. +The autonomous loop upgrades the agent from single-turn Q&A to **long-running tasks that drive themselves forward across many invocations, keep state externally, and stop on verifiable goal completion**. Alongside normal chat (one-shot) and plan mode (linear multi-step), it is a third execution form: a run-level self-driving loop. The design tracks Codex `/goal` (the Ralph Loop): the goal stays alive across turns and the loop runs until the job is done, while strictly keeping maker ≠ checker — the agent doing the work is never the one grading it. ## Core loop ``` -read state (persistent sandbox files) → agent runs one iteration (fresh context, same persistent sandbox) - → environment verification (verify_cmd) → evaluator verdict → feedback + compaction handoff → next iteration +Scout (read-only workspace survey) → Plan (requirement ledger, optional check commands) → per iteration: + worker runs one round (fresh context, same persistent sandbox, exactly one requirement) + → machine check (driver itself runs check_cmd; exit code 0 = objectively met) + → read-only reviewer personally verifies the real output (never trusts self-reports) + → flip / feed back / stall bookkeeping + → replan the remaining work when a requirement gets shelved → done when all pass ``` -Each iteration gets a fresh context (avoiding long-session degradation); work artifacts and progress live in files in the persistent sandbox (`PROGRESS.md` / `state.json` / `handoffs.md`) — state lives on disk, not in the context window. +- **Recon-grounded planning**: before work starts, a read-only scout `ls`/`read`/`grep`s the project or /workspace to establish ground truth (what exists, what's missing, what the pitfalls are); the planner decomposes the goal against that survey instead of guessing from the goal text. Task-style loops with an empty workspace skip the scout automatically. +- **Requirement ledger (feature_list.json)**: owned exclusively by the driver; the worker cannot add, delete, or edit entries and is fed exactly one requirement per iteration. Simple goals may decompose into just 1–2 requirements; complex ones cap at 8. +- **Hybrid acceptance**: requirements that can be judged objectively carry a read-only `check_cmd` (e.g. `test -f`, `grep -c`, word-count reconciliation) which the **driver itself executes in the sandbox** — the worker cannot cheat it. A failing check feeds the command output straight into the next round without burning a reviewer run. Semantics and quality are always verified by the **read-only reviewer subagent** opening the real files. +- **Fewer second passes**: a requirement whose machine check passed flips on a single semantic review; purely semantic requirements — and the final requirement whose flip completes the loop — still require an independent second-pass re-check (guards against premature completion). +- **State lives on disk**: every iteration starts with a fresh context (avoiding long-session degradation); artifacts and progress live in the persistent sandbox (`feature_list.json` / `PROGRESS.md` / `handoffs.md`), with a DB mirror of the ledger as the source of truth across restarts and machine changes. +- **Mid-run replanning**: when a requirement stalls for several rounds and gets shelved, the planner re-decomposes the *remaining* work (passed items are immutable) instead of shelving requirements one by one into a "partially done" ending. Replans are capped (`LOOP_MAX_REPLANS`, default 2). -## Two ways to start +## Stopping: run until done — budgets are optional -| Form | Entry | Goal & verification | -|---|---|---| -| **Conversational** (`self_verify`) | The "Autonomous Loop" toggle in the chat composer (next to "Plan Mode") | Just describe the goal in natural language — no verification config to fill in. Iterations stream back into the current conversation as normal assistant messages (a markdown transcript). | -| **Form** (`verify`) | Lab module → Autonomous Loop | Explicitly fill in `verify_cmd` / target score / budget; suited to cases with a known verification command (e.g. EdgeBench evaluation). | - -**Conversational evaluation auto-picks one of two paths** (the worker chooses each round by the nature of the goal; the evaluator routes accordingly): - -- **Quantifiable goal** (cost / error / pass-rate…) → the worker **writes its own** `/workspace/verify.sh`; the driver runs it independently for **rule-based scoring** (ground truth, worker self-reports not trusted). Without an explicit threshold, completion comes from **stagnation convergence**: once a valid solution exists and several consecutive rounds show no meaningful gain, it is judged done — ensuring genuine iterate-and-improve rather than exiting on the first valid solution. -- **Qualitative goal** (copy / design / polish…) → the worker produces the deliverable + an evidence note and **writes no script**; at startup one LLM call decomposes the goal into acceptance criteria, then an **independent LLM evaluator** (invoked by the driver each round, never callable by the worker — avoiding self-evaluation bias) checks the criteria one by one to emit `done / continue`. +The primary stop condition is **all ledger requirements passing**. Budgets (iterations / wall clock / tokens) default to **unlimited** (`0` or omitted = no cap); explicit positive values still apply. The anti-runaway guards are independent of budgets and always active: -## Exit decision: environment ground truth first +1. **Stall guard**: a requirement with N consecutive rounds (orchestration profile `max_attempts_per_requirement`, default 6) without reviewer-affirmed material progress gets shelved (triggering replan / HITL). The reviewer explicitly judges `progress` each round, so healthy multi-round work (e.g. writing a long document chapter by chapter) is not penalized. +2. **Failure circuit breaker**: a worker round that raises or produces nothing is treated as an environment outage — not counted as an attempt, retried after a 30s backoff; only `LOOP_MAX_CONSECUTIVE_INFRA` (default 6) consecutive such rounds trip the loop to `failed`, resumable once the environment recovers. +3. **Hard backstop**: `LOOP_HARD_MAX_ITERS` (default 500) — far beyond any real task, purely an infinite-loop fuse. -Reliability order of stop conditions: +The final flip always passes an **independent second-pass review**, and partially-completed endings automatically run one **wrap-up delivery round** (`LOOP_WRAPUP`, default on): consolidate what passed into a usable deliverable and honestly list what remains. -1. **Environment verification (primary)**: `goal_spec.verify_cmd` runs in the persistent sandbox; success is decided by exit code + output (tests pass / metric met / target file exists). In conversational mode this script is authored by the worker and run independently by the driver. Anything the environment can verify deterministically does not go through the LLM. -2. **Evaluator (fallback)**: when the goal cannot be fully verified by command, an independent evaluator reads the environment evidence and checks acceptance criteria one by one, emitting a binary verdict (done / continue / off_track). The evaluator is separate from the worker and invoked deterministically by the loop driver each round, avoiding self-evaluation bias. -3. **Budget backstop**: max iterations / wall-clock / cumulative tokens — stops on breach (the guardrail for unattended runs). +## Terminal states -On a `done` verdict, a **second verification** (re-running verify) prevents false-positive early delivery. +| State | Meaning | +|---|---| +| `completed` | All ledger requirements passed (including the final second-pass check) | +| `budget_exhausted` | Partially done: requirements shelved with no replan left, or an explicit budget / hard backstop hit | +| `cancelled` | Cancelled by the user | +| `awaiting_human` | HITL enabled and the reviewer requested human input | +| `interrupted` | Interrupted by a service restart (reconciled at startup); resumable from the checkpoint | +| `failed` | Tripped by consecutive infrastructure failures (resumable after recovery) | -## Termination outcomes +## Long-run reliability (harness) -| Terminal state | Meaning | -|---|---| -| `completed` | Environment verification passed (with second check) | -| `budget_exhausted` | Hit iteration / wall-clock / token budget | -| `cancelled` | User cancelled | -| `awaiting_human` | With HITL enabled, evaluator requested a human (optional) | +- **No age-based kill**: autonomous-loop runs are exempt from the platform-wide hard run-age cap — a loop still making healthy progress in-process is never reaped for merely running long; orphaned runs are still cleaned up by the stream-quiet rule. +- **Checkpoint resume**: dual-source ledger (DB mirror + sandbox); at startup, orphaned `running` loops are reconciled to `interrupted` (or auto-resumed with `LOOP_AUTO_RESUME=true`). +- **Resume keeps parameters**: the model, reviewer model, worker iteration cap, and thinking level persist with the loop — resuming never silently downgrades to defaults. +- **Mid-run steering**: add an instruction from the plan bar without cancelling; the driver picks it up before the next iteration and injects it into the worker prompt at top priority. -## Capabilities +## Model configuration -- **Dynamic todos**: the agent maintains an editable todo list in `state.json` each round, checking items off. -- **Self-correction**: several rounds without meaningful score improvement automatically prompts "try a fundamentally different strategy". -- **Crash resume**: after a restart, the persistent sandbox files remain, so the loop resumes from `state.json` (`LOOP_AUTO_RESUME` enabled). -- **Scheduled advancement**: scheduled tasks support a `loop` type that advances the same persistent loop on a cron cycle (rather than creating a new stateless task each time). -- **Human checkpoints (HITL)**: default is fully automatic ("record and continue"); optionally enabled per-loop to pause at key points and `/resume` after human approval. +- **Worker model**: follows the model the user selected in the conversation (same source as normal chat). +- **Reviewer/planner model**: configured independently in the admin console under **Model Management → Role Assignment → Autonomous-loop review & planning** (`loop_reviewer`); falls back to the main agent model when unassigned. The scout, decomposition, reviews, second passes, and verdict rescue all share this role. ## API | Method | Path | Description | |---|---|---| -| POST | `/v1/loops` | Create a loop (`goal_spec` + `budget`) | +| POST | `/v1/loops` | Create a loop (`goal_spec`; `budget` optional = unlimited) | | GET | `/v1/loops` / `/v1/loops/{id}` | List / detail | -| POST | `/v1/loops/{id}/start` | Start (SSE streaming) | -| POST | `/v1/loops/{id}/resume` | Resume (after HITL approval / crash) | -| POST | `/v1/loops/{id}/cancel` | Cancel | +| POST | `/v1/loops/{id}/start` | Start (SSE stream) | +| POST | `/v1/loops/{id}/resume` | Resume (after HITL approval / interruption; omitted params are restored from the last start) | +| POST | `/v1/loops/{id}/steer` | Queue a mid-run instruction (applies before the next iteration) | +| POST | `/v1/loops/{id}/cancel` | Cancel (reconciles the status directly when no run is active) | | GET | `/v1/loops/{id}/iterations` | Iteration audit trail | -Frontend entry: the **"Autonomous Loop" toggle in the chat composer** (conversational mode, `components/chat/InputArea.tsx` + `hooks/useLoopMode.ts`) or **Lab module → Autonomous Loop** (form mode, `components/lab/LabPanel.tsx`). Access is gated by the `can_run_autonomous_loop` capability (enabled by default, can be disabled per user / team). +Frontend entry: the **"Autonomous loop" toggle in the chat input** (`components/chat/InputArea.tsx` + `hooks/useLoopMode.ts`); the plan bar (`components/loop/LoopPlanBar.tsx`) shows the live requirement checklist and review state, with "Resume" and mid-run steering built in. Gated by the `can_run_autonomous_loop` capability (on by default; can be disabled per user / team). ## Related code -- Driver `orchestration/autonomous_loop.py`, evaluator `orchestration/loop_evaluator.py` -- ChatRun integration `orchestration/chat_run_executor.py` (`start_autonomous_loop_run`) -- Service `core/services/loop_service.py`, API `api/routes/v1/loops.py` +- Driver `orchestration/autonomous_loop.py`, planner `orchestration/loop_planner.py`, reviewer `orchestration/subagents/loop_reviewer.py` +- ChatRun integration `orchestration/chat_run_executor.py` (`start_autonomous_loop_run` / startup reconciliation `resume_running_loops`) +- Service `core/services/loop_service.py` (ledger mirror / start params / steering queue), API `api/routes/v1/loops.py` - Tables `agent_loops` / `loop_iterations` diff --git a/document/en/modules/chat.md b/document/en/modules/chat.md index b3177e4d..ffbf542e 100644 --- a/document/en/modules/chat.md +++ b/document/en/modules/chat.md @@ -1,6 +1,6 @@ # Chat & Agent Orchestration -> Last updated: July 30, 2026 +> Last updated: August 12, 2026 Chat is the core pipeline of HugAgentOS: a user message travels through the FastAPI route, runtime-context assembly, and the streaming orchestrator, then an AgentScope 2.0 ReActAgent drives multi-turn "think → call tool → observe" loops whose events are pushed to the frontend in real time over SSE. This page walks the end-to-end flow as it exists in the code, then covers the citation system, plan mode, sub-agents, conversation summarization, chat sharing, context compression, and oversized-tool-result offloading. @@ -25,7 +25,7 @@ orchestration/workflow.py::astream_chat_workflow() │ │ MCP pool + skill registration + file tools + system prompt + middlewares → Agent │ ├─ core/llm/context_manager.py trim history to the token budget │ └─ orchestration/streaming.py::StreamingAgent.stream() - │ consumes agent.reply_stream(), maps 25 fine-grained events to 8 SSE event kinds + │ consumes agent.reply_stream(), coalesces 25 event kinds while preserving tool-argument deltas ▼ SSE follower: chat_run_executor.follow_run_as_sse() XRANGE replay + XREAD tail → data: {...}\n\n → browser @@ -59,18 +59,20 @@ Defensive machinery: a `: heartbeat` SSE comment line every 15 silent seconds (k ## SSE event types and payloads -`orchestration/streaming.py::StreamingAgent` collapses AgentScope 2.0 `reply_stream` events into 8 internal kinds; `workflow.py` and `chats.py::_stream_sse_response` enrich them with chat-level fields before they hit the wire. Events as the frontend sees them: +`orchestration/streaming.py::StreamingAgent` coalesces AgentScope 2.0 `reply_stream` events into internal events. Tiny tool-argument fragments are batched at 256 characters or 50ms, keeping them visible without recreating an SSE event storm. `workflow.py` and `chats.py::_stream_sse_response` enrich them with chat-level fields before they hit the wire. Events as the frontend sees them: | `type` | Meaning | Key fields | |---|---|---| | `thinking` | Reasoning (delta or stage hint) | `delta` / `message` | | `content` | Answer text delta | `event: "ai_message"`, `delta`, `chat_id` | | `content_replace` | Replaces the streamed draft in place when ontology review revises the final answer | `content`, `reason: "ontology_review"`, `chat_id` | -| `tool_call` | A tool invocation (args complete) | `tool_name`, `tool_display_name`, `tool_args`, `tool_id`, plus `subagent_name` for sub-agent calls | +| `tool_call_start` | Tool-call construction starts; the frontend opens one card by stable ID | `tool_name`, `tool_display_name`, `tool_id` | +| `tool_call_delta` | Incremental argument JSON appended to the same card | `tool_name`, `tool_id`, `arguments_delta` | +| `tool_call` | Arguments are complete and execution is about to start | `tool_name`, `tool_display_name`, `tool_args`, `tool_id`, plus `subagent_name` for sub-agent calls | | `tool_result` | Tool invocation result | `tool_name`, `result`, `tool_id`, `citations[]` | | `subagent_event` | Child execution details nested under the parent `call_subagent` card | `parent_tool_id`, `sub_type`, `agent_name`, plus child tool or content fields | | `ontology_activation` / `ontology_gate` / `ontology_review` | Ontology-governance state, separate from model reasoning | workflow activation, gate decision, and committee status or verdict | -| `tool_pending` | Tool started, args still streaming | `tool_name` | +| `tool_pending` | Waiting fallback when the provider exposes no parseable argument deltas | `reason` | | `batch_confirm` | Batch plan generated, awaiting user confirmation (human gate) | `plan_id`, `total`, `preview`, `default_template`, `placeholder_keys` | | `file_confirm` | A tool is suspended awaiting confirmation of a MySpace write | confirmation context; the tool resumes in place after an out-of-band `POST /v1/chats/{chat_id}/file-confirm` | | `compaction_notice` | A new context-compaction checkpoint was created after the previous turn | `chat_id`, `context_compaction` (coverage boundary and replacement-summary token count) | @@ -81,6 +83,12 @@ Defensive machinery: a `: heartbeat` SSE comment line every 15 silent seconds (k The stream terminates with `data: [DONE]`. Example frames: ``` +data: {"type":"tool_call_start","tool_name":"internet_search","tool_display_name":"Web Search","tool_id":"call_abc"} + +data: {"type":"tool_call_delta","tool_name":"internet_search","arguments_delta":"{\"query\":\"Beijing ","tool_id":"call_abc"} + +data: {"type":"tool_call_delta","tool_name":"internet_search","arguments_delta":"IC industry\"}","tool_id":"call_abc"} + data: {"type":"tool_call","tool_name":"internet_search","tool_display_name":"Web Search","tool_args":{"query":"Beijing IC industry"},"tool_id":"call_abc"} data: {"type":"tool_result","tool_name":"internet_search","result":{...},"tool_id":"call_abc","citations":[{"id":"e1","title":"...","url":"...","snippet":"...","source_type":"internet","item_index":0}]} diff --git a/document/zh-CN/api/overview.md b/document/zh-CN/api/overview.md index 2af8e3b6..981f2608 100644 --- a/document/zh-CN/api/overview.md +++ b/document/zh-CN/api/overview.md @@ -109,9 +109,11 @@ curl -N http://localhost:3000/api/v1/chats/stream \ | `run_started` | 流的第一帧 | `run_id`(用于续播/取消)、`message_id`、`chat_id` | | `thinking` | 推理/思考阶段 | `message`(阶段提示)或 `delta`(思考增量文本) | | `content` | 正文文本增量 | `event: "ai_message"`、`delta`、`chat_id` | -| `tool_call` | Agent 发起工具调用 | `tool_name`、`tool_display_name`、`tool_args`、`tool_id`、`subagent_name?` | +| `tool_call_start` | 模型开始构造工具调用 | `tool_name`、`tool_display_name`、`tool_id` | +| `tool_call_delta` | 工具参数 JSON 增量(后端合批后下发) | `tool_name`、`tool_id`、`arguments_delta` | +| `tool_call` | 参数完整、即将执行工具 | `tool_name`、`tool_display_name`、`tool_args`、`tool_id`、`subagent_name?` | | `tool_result` | 工具返回结果 | `tool_name`、`result`(JSON)、`tool_id`、`citations`(引用项列表) | -| `tool_pending` | 模型缓冲工具参数/调用启动间隙 | `reason`(如 `tool_call_start` / `llm_buffering`) | +| `tool_pending` | 提供商未暴露可解析增量时的等待兜底 | `reason`(如 `llm_buffering`) | | `file_confirm` | 工具挂起等待用户确认「我的空间」写操作 | `confirm_id`、`op`、`logical_path`、`message`、`expired`;流不结束,用户带外 `POST /v1/chats/{chat_id}/file-confirm` 后续跑 | | `batch_confirm` | 批量执行计划等待用户确认 | `plan_id`、`total`、`preview`、`default_template`、`placeholder_keys`;确认走 `POST /v1/batch/{plan_id}/confirm` | | `meta` | 回答结束的收尾帧 | `route`、`sources`、`artifacts`、`citations`、`warnings`、`is_markdown`、`message_id`、`workspace_files` | @@ -124,6 +126,12 @@ data: {"type": "run_started", "run_id": "run_9f8e7d", "message_id": "msg_001", " data: {"type": "thinking", "message": "正在分析您的问题...", "chat_id": "chat_abc123"} +data: {"type": "tool_call_start", "tool_name": "internet_search", "tool_display_name": "联网搜索", "tool_id": "call_01", "chat_id": "chat_abc123"} + +data: {"type": "tool_call_delta", "tool_name": "internet_search", "tool_id": "call_01", "arguments_delta": "{\"query\":\"北京 ", "chat_id": "chat_abc123"} + +data: {"type": "tool_call_delta", "tool_name": "internet_search", "tool_id": "call_01", "arguments_delta": "今天 天气\"}", "chat_id": "chat_abc123"} + data: {"type": "tool_call", "tool_name": "internet_search", "tool_display_name": "联网搜索", "tool_args": {"query": "北京 今天 天气"}, "tool_id": "call_01", "chat_id": "chat_abc123"} data: {"type": "tool_result", "tool_name": "internet_search", "result": {"result": {"query": "北京 今天 天气"}}, "tool_id": "call_01", "citations": [{"id": "internet_search-1", "title": "..."}], "chat_id": "chat_abc123"} diff --git a/document/zh-CN/architecture/frontend.md b/document/zh-CN/architecture/frontend.md index 6c421a12..4b07a47a 100644 --- a/document/zh-CN/architecture/frontend.md +++ b/document/zh-CN/architecture/frontend.md @@ -83,7 +83,7 @@ SSE 流式不走 `api.ts` 的 JSON 通道,由 `hooks/useStreaming.ts` 直接 | Hook | 职责 | |---|---| -| `useStreaming` | SSE 主消费器:暴露 `send` / `abort` / `regenerate` / `editAndResend` / `resumeRunIfAny`,解析 `content/thinking/tool_call/tool_result/tool_progress/meta/error` 事件,维护文本分段与工具时间线,支持 run 续播 | +| `useStreaming` | SSE 主消费器:暴露 `send` / `abort` / `regenerate` / `editAndResend` / `resumeRunIfAny`,解析 `content/thinking/tool_call_start/tool_call_delta/tool_call/tool_result/tool_progress/meta/error` 事件,维护文本分段与工具时间线,支持 run 续播 | | `useChatActions` | 会话管理动作封装:新建 / 删除 / 重命名 / 置顶收藏 / 导出 / 分享 / 摘要与分类 | | `useChatInit` | 应用启动时的会话初始化与活动 run 恢复 | | `usePlanMode` | 计划模式 SSE 消费器(首次执行与续播共用) | @@ -121,7 +121,7 @@ useStreaming.send │ 2. fetch POST /v1/chats/stream(附件只携带 file_id/name/mime_type) │ 逐行解析 data: {json} │ ├─ content/thinking → segments 分段追加(utils/segments.ts) - │ ├─ tool_call/tool_result → 工具时间线(components/tool/) + │ ├─ tool_call_start/tool_call_delta/tool_call/tool_result → 按 tool_id 原位更新工具时间线(components/tool/) │ ├─ file_confirm/batch_confirm → 确认条 / 弹窗(真挂起) │ └─ meta → 引用源、产物列表写入消息 ▼ diff --git a/document/zh-CN/architecture/overview.md b/document/zh-CN/architecture/overview.md index 76deb3d2..5c47f262 100644 --- a/document/zh-CN/architecture/overview.md +++ b/document/zh-CN/architecture/overview.md @@ -163,14 +163,16 @@ RAG 则提供支撑决策所需的文档与证据。 |---|---|---| | `content`(`event: ai_message`) | 正文文本增量 | `chats.py::_stream_sse_response` | | `thinking` | 深度思考增量 | `core/chat/tool_log.py::build_thinking_event` | -| `tool_call` | 工具调用开始(名称 + 参数) | `core/chat/tool_log.py` | +| `tool_call_start` | 工具调用开始(稳定 ID + 名称) | `core/chat/tool_log.py` | +| `tool_call_delta` | 合批后的工具参数 JSON 增量 | `core/chat/tool_log.py` | +| `tool_call` | 工具参数完整、即将执行 | `core/chat/tool_log.py` | | `tool_result` | 工具执行结果(含引用、产物卡片载荷) | `core/chat/tool_log.py` + `orchestration/tool_payloads.py` | | `tool_progress` | 长工具的进度上报 | `chats.py` | | `batch_confirm` / `file_confirm` | 批量执行确认、「我的空间」写操作确认(真挂起门控) | `chats.py` | | `meta` | 末尾元信息:路由、引用源、产物列表等 | `orchestration/workflow.py` | | `error` | 错误事件(随后立即 `[DONE]`) | `chats.py` | -前端 `src/frontend/src/hooks/useStreaming.ts` 按 `type` 分发,把文本、工具时间线、引用增量渲染进消息气泡。 +前端 `src/frontend/src/hooks/useStreaming.ts` 按 `type` 分发,并按 `tool_id` 把 start、delta、最终调用与结果原位合并为一张工具卡片。 ## 容器拓扑 diff --git a/document/zh-CN/modules/autonomous-loop.md b/document/zh-CN/modules/autonomous-loop.md index 26e5043b..d1c5d60a 100644 --- a/document/zh-CN/modules/autonomous-loop.md +++ b/document/zh-CN/modules/autonomous-loop.md @@ -1,73 +1,77 @@ # 自主循环(Autonomous Loop) -> 最后更新:2026-07-10 +> 最后更新:2026-08-13 -自主循环让智能体从「一问一答」升级为**能自我推进、跨多次调用、维持外部状态、按可验证目标自主停止的长时运行任务**。它在普通对话(一问一答)与计划模式(线性多步)之外,提供第三种运行形态:一个 run 级的自驱动循环。 +自主循环让智能体从「一问一答」升级为**能自我推进、跨多次调用、维持外部状态、按可核验目标自主停止的长时运行任务**。它在普通对话(一问一答)与计划模式(线性多步)之外,提供第三种运行形态:一个 run 级的自驱动循环。设计对标 Codex `/goal`(Ralph Loop):目标跨轮存活、做完为止,同时坚持 maker≠checker——干活的与判卷的永远是两个智能体。 ## 核心回路 ``` -读状态(持久沙箱文件) → 智能体跑一轮(全新上下文, 同一持久沙箱) → 环境验证(verify_cmd) - → 评估器判 verdict → 反馈回灌 + 压缩交接 → 下一轮 +侦察(只读摸清工作区) → 规划(拆需求账本, 可附机检命令) → 每轮: + worker 跑一轮(全新上下文, 同一持久沙箱, 一次只啃一条需求) + → 机检(driver 亲自执行 check_cmd, 退出码 0 = 客观达标) + → 只读评审员亲验真实产出(不采信 worker 自报) + → 翻牌 / 反馈回灌 / 停滞计数 + → 需求被搁置时对剩余部分重规划 → 全部通过即完成 ``` -每轮迭代拿到全新上下文(避免长会话退化),工作产物与进度落在持久沙箱的文件里(`PROGRESS.md` / `state.json` / `handoffs.md`)——状态存磁盘、不堆在上下文里。 +- **侦察式规划**:开工前一个只读侦察员先 `ls`/`read`/`grep` 摸清项目或 /workspace 实况(已有什么、缺什么、有什么坑),规划模型据实拆账本——不再凭目标文本盲拆。纯任务型循环且工作区为空时自动跳过侦察。 +- **需求账本(feature_list.json)**:driver 独占,worker 无权增删改;每轮只喂当前一条需求。简单目标允许只拆 1~2 条,复杂目标最多 8 条。 +- **混合验收**:能用命令客观判定的需求在规划时附一条只读 `check_cmd`(如 `test -f`、`grep -c`、字数对账),由 **driver 亲自在沙箱执行**——worker 无法作弊;机检未过直接把命令输出回灌下一轮(不消耗一次评审)。语义与质量始终由**只读评审子智能体**打开真实文件核验。 +- **二次复核降频**:机检已过的需求单次评审即可翻牌;纯语义需求、以及「翻牌即整环完成」的收官需求,仍需独立二次复核(防提前收工)。 +- **状态存磁盘**:每轮迭代全新上下文(避免长会话退化),产物与进度落在持久沙箱(`feature_list.json` / `PROGRESS.md` / `handoffs.md`),账本另有 DB 镜像(重启/换机后以 DB 为准续跑)。 +- **运行中重规划**:某条需求连续多轮无实质推进被搁置时,规划器对「剩余未完成部分」重拆(已通过项不动、不可撤销),而不是一条条搁置到「部分完成」收场。重拆次数有护栏(`LOOP_MAX_REPLANS`,默认 2)。 -## 两种发起形态 +## 停止条件:做完为止,预算是可选项 -| 形态 | 入口 | 目标与验证 | -|---|---|---| -| **对话模式**(`self_verify`) | 聊天输入框的「自主循环」开关(与「计划模式」并列) | 用自然语言描述目标即可,无需手填任何验证配置。迭代以普通助手消息(markdown 转录)实时回灌到当前会话。 | -| **表单模式**(`verify`) | 实验室模块 → 自主循环 | 显式填 `verify_cmd` / 目标分 / 预算,适合已有确定验证命令的场景(如 EdgeBench 评测)。 | - -**对话模式的评估自动二选一**(worker 每轮按目标性质自选,评估器随之路由): - -- **可量化目标**(成本 / 误差 / 通过率…)→ worker **自建** `/workspace/verify.sh`,驱动器独立跑它做**规则评分**(ground truth,不采信 worker 自报)。无显式阈值时靠**停滞收敛**收口:已有合法解且连续多轮无实质提升即判达成——保证真的"反复迭代自我提升",而非首个合法解就退出。 -- **定性目标**(文案 / 方案 / 润色…)→ worker 产出成果 + 证据说明、**不写脚本**;启动时先用一次 LLM 把目标拆成验收标准,之后由**独立 LLM 评估器**(driver 每轮直调、worker 不可调,防自评偏置)按标准逐条判 `done / continue`。 - -## 退出判定:环境 ground truth 优先 +循环的第一停止条件是**需求账本全部通过**。预算(迭代数/墙钟/token)默认**不设上限**(传 `0` 或不传即不限)——显式传正数仍然生效。防失控的护栏与预算无关,始终在场: -退出条件的可靠性顺序: +1. **停滞护栏**:一条需求连续 N 轮(编排 Profile 的 `max_attempts_per_requirement`,默认 6)无评审员确证的实质推进 → 搁置(触发重规划 / HITL)。评审员每轮显式判 `progress`——逐章写长文这类"健康推进多轮"不会被误伤。 +2. **异常熔断**:worker 单轮抛错/零产出按环境故障处理——不计尝试、退避 30s 重试;连续 `LOOP_MAX_CONSECUTIVE_INFRA`(默认 6)轮才熔断为 `failed`,排除故障后可从断点续跑。 +3. **防失控硬后备**:`LOOP_HARD_MAX_ITERS`(默认 500)轮,远超正常任务量级,仅防真正的死循环。 -1. **环境验证(主)**:`goal_spec.verify_cmd` 在持久沙箱执行,按退出码 + 输出判达成(如测试通过 / 指标达标 / 目标文件存在)。对话模式下这个脚本由 worker 自建、驱动器独立运行。能被环境确定性验证的,不走大模型判断。 -2. **评估器(补)**:目标无法完全靠命令验证时,独立评估器读取环境证据 + 验收标准逐条核对,输出二元判定(done / continue / off_track)。评估器与执行体分离、由循环驱动器每轮确定性调用,避免执行体自评的偏置。 -3. **预算兜底**:最大迭代数 / 墙钟 / 累计 token 触顶即停(无人值守下的护栏)。 +判 `done` 的收官需求会**独立二次复核**,防止误判提前交付。**部分完成收场时自动跑一轮收尾交付**(`LOOP_WRAPUP`,默认开):整合已完成部分为可用交付物、如实列出未竟事项。 -判定 `done` 时会**二次复验**(复跑 verify),防止误判提前交付。 - -## 三类退出出口 +## 终态 | 终态 | 含义 | |---|---| -| `completed` | 环境验证达标(含二次复验) | -| `budget_exhausted` | 触及迭代 / 墙钟 / token 预算 | +| `completed` | 需求账本全部通过(收官二次复核在内) | +| `budget_exhausted` | 部分完成:有需求停滞被搁置且重规划无解,或触及显式预算/硬后备 | | `cancelled` | 用户取消 | -| `awaiting_human` | 开启 HITL 时,评估器请求人工(可选) | +| `awaiting_human` | 开启 HITL 时,评审请求人工介入 | +| `interrupted` | 服务重启导致中断(启动对账自动归位),可「继续」断点续跑 | +| `failed` | 连续基础设施故障熔断(排障后可续跑) | + +## 长跑可靠性(harness) + +- **不设年龄硬顶**:自主循环 run 豁免平台统一的运行时长硬顶——进程内还在健康推进的 loop 永不因「跑得久」被回收;孤儿 run 仍按「流静默」判据清理。 +- **断点恢复**:账本 DB 镜像 + 持久沙箱双源;服务重启后启动对账把孤儿 loop 归位为 `interrupted`(`LOOP_AUTO_RESUME=true` 时自动续跑)。 +- **续跑不丢参**:模型/评审模型/轮数/思考档位随 loop 持久化,崩溃后续跑不会悄悄降级到默认模型。 +- **运行中转向(steer)**:无需取消重来——计划条上直接追加指令,driver 下一轮开工前取走并以最高优先级注入 worker。 -## 能力要点 +## 模型配置 -- **动态待办**:智能体每轮在 `state.json` 维护可增删的待办清单,逐项打勾。 -- **自我修正**:连续多轮分数无实质提升 → 自动提示「换一个根本不同的策略」重做。 -- **断点恢复**:进程重启后,持久沙箱文件仍在 → 从 `state.json` 断点续跑(`LOOP_AUTO_RESUME` 开启)。 -- **定时推进**:定时任务支持 `loop` 类型,按 cron 周期推进同一个持久循环(而非每次新建无状态任务)。 -- **人工检查点(HITL)**:默认「记录后继续」全自动;per-loop 可选开启,在关键点暂停等人工批准后 `/resume` 续跑。 +- **worker 模型**:跟随用户在会话里选定的模型(与普通聊天同源)。 +- **评审/规划模型**:后台「模型管理 → 角色分配 → **自主循环评审与规划**(`loop_reviewer`)」独立配置;未配置回落主智能体模型。侦察、拆解、评审、二次复核、收尾判定共用该角色。 ## API | 方法 | 路径 | 说明 | |---|---|---| -| POST | `/v1/loops` | 创建循环(`goal_spec` + `budget`) | +| POST | `/v1/loops` | 创建循环(`goal_spec`,`budget` 可省=不限) | | GET | `/v1/loops` / `/v1/loops/{id}` | 列表 / 详情 | | POST | `/v1/loops/{id}/start` | 启动(SSE 流式跟随) | -| POST | `/v1/loops/{id}/resume` | 续跑(HITL 批准后 / 崩溃后断点续跑) | -| POST | `/v1/loops/{id}/cancel` | 取消 | +| POST | `/v1/loops/{id}/resume` | 续跑(HITL 批准后 / 中断后断点续跑,缺省参数自动回读上次启动参数) | +| POST | `/v1/loops/{id}/steer` | 运行中追加指令(下一轮开工前生效) | +| POST | `/v1/loops/{id}/cancel` | 取消(无活跃任务时直接归位状态) | | GET | `/v1/loops/{id}/iterations` | 迭代审计轨迹 | -前端入口:**聊天输入框「自主循环」开关**(对话模式,`components/chat/InputArea.tsx` + `hooks/useLoopMode.ts`)或**实验室模块 → 自主循环**(表单模式,`components/lab/LabPanel.tsx`)。权限由能力位 `can_run_autonomous_loop` 控制(默认开启,可按用户 / 团队关闭)。 +前端入口:**聊天输入框「自主循环」开关**(`components/chat/InputArea.tsx` + `hooks/useLoopMode.ts`),计划条(`components/loop/LoopPlanBar.tsx`)实时显示需求清单、评审状态,并提供「继续」与运行中追加指令。权限由能力位 `can_run_autonomous_loop` 控制(默认开启,可按用户 / 团队关闭)。 ## 相关代码 -- 驱动器 `orchestration/autonomous_loop.py`、评估器 `orchestration/loop_evaluator.py` -- ChatRun 接入 `orchestration/chat_run_executor.py`(`start_autonomous_loop_run`) -- 服务 `core/services/loop_service.py`、API `api/routes/v1/loops.py` +- 驱动器 `orchestration/autonomous_loop.py`、规划器 `orchestration/loop_planner.py`、评审员 `orchestration/subagents/loop_reviewer.py` +- ChatRun 接入 `orchestration/chat_run_executor.py`(`start_autonomous_loop_run` / 启动对账 `resume_running_loops`) +- 服务 `core/services/loop_service.py`(账本镜像 / 启动参数 / steering 队列)、API `api/routes/v1/loops.py` - 数据表 `agent_loops` / `loop_iterations` diff --git a/document/zh-CN/modules/chat.md b/document/zh-CN/modules/chat.md index 69ac57b0..2056fbb9 100644 --- a/document/zh-CN/modules/chat.md +++ b/document/zh-CN/modules/chat.md @@ -1,6 +1,6 @@ # 对话与智能体编排 -> 最后更新:2026-07-30 +> 最后更新:2026-08-12 对话是 HugAgentOS 的核心链路:一条用户消息经过 FastAPI 路由、运行时上下文装配、流式编排器,最终由 AgentScope 2.0 的 ReActAgent 驱动多轮「思考 → 调工具 → 观察」循环,并以 SSE 事件流实时推送到前端。本篇按真实代码走一遍端到端流程,并展开引用系统、计划模式、子智能体、会话摘要、会话分享、上下文压缩与超长结果 offload 等子能力。 @@ -25,7 +25,7 @@ orchestration/workflow.py::astream_chat_workflow() │ │ MCP 连接池 + 技能注册 + 文件工具 + 系统提示词 + 中间件 → Agent │ ├─ core/llm/context_manager.py 历史按 token 预算裁剪 │ └─ orchestration/streaming.py::StreamingAgent.stream() - │ 消费 agent.reply_stream(),把 25 种细粒度事件映射为 8 类 SSE 事件 + │ 消费 agent.reply_stream(),归并 25 种细粒度事件并保留工具参数增量 ▼ SSE follower:chat_run_executor.follow_run_as_sse() XRANGE 重放 + XREAD 续播 → data: {...}\n\n → 浏览器 @@ -59,18 +59,20 @@ SSE follower:chat_run_executor.follow_run_as_sse() ## SSE 事件类型与负载 -`orchestration/streaming.py::StreamingAgent` 把 AgentScope 2.0 `reply_stream` 的细粒度事件归并为内部 8 类,`workflow.py` 与 `chats.py::_stream_sse_response` 再补充会话级字段后落到 wire 上。前端实际收到的事件: +`orchestration/streaming.py::StreamingAgent` 把 AgentScope 2.0 `reply_stream` 的细粒度事件归并为内部事件;工具参数小分片按 256 字符或 50ms 合批,兼顾实时可见与 SSE 事件量。`workflow.py` 与 `chats.py::_stream_sse_response` 再补充会话级字段后落到 wire 上。前端实际收到的事件: | `type` | 含义 | 关键字段 | |---|---|---| | `thinking` | 思考过程(增量或阶段提示) | `delta` / `message` | | `content` | 回答正文增量 | `event: "ai_message"`, `delta`, `chat_id` | | `content_replace` | 本体评审修订了已流式展示的草稿时,原位替换最终答案 | `content`, `reason: "ontology_review"`, `chat_id` | -| `tool_call` | 一次工具调用(参数已完整) | `tool_name`, `tool_display_name`, `tool_args`, `tool_id`,调子智能体时附 `subagent_name` | +| `tool_call_start` | 开始构造一次工具调用;前端按稳定 ID 创建一张卡片 | `tool_name`, `tool_display_name`, `tool_id` | +| `tool_call_delta` | 工具参数 JSON 增量;前端在同一卡片中追加 | `tool_name`, `tool_id`, `arguments_delta` | +| `tool_call` | 工具参数已完整、即将执行 | `tool_name`, `tool_display_name`, `tool_args`, `tool_id`,调子智能体时附 `subagent_name` | | `tool_result` | 工具调用结果 | `tool_name`, `result`, `tool_id`, `citations[]` | | `subagent_event` | 子智能体内部过程,挂在父 `call_subagent` 卡片下 | `parent_tool_id`, `sub_type`, `agent_name`,以及内部工具或内容字段 | | `ontology_activation` / `ontology_gate` / `ontology_review` | 本体治理状态,不属于模型思考 | 工作流、门禁决策、委员会状态与结论 | -| `tool_pending` | 工具已开始、参数仍在流式生成 | `tool_name` | +| `tool_pending` | 提供商没有暴露可解析参数增量时的等待兜底 | `reason` | | `batch_confirm` | 批量计划生成完毕,等待用户确认(人审门) | `plan_id`, `total`, `preview`, `default_template`, `placeholder_keys` | | `file_confirm` | 工具挂起等待用户确认「我的空间」写操作 | 确认上下文;用户带外 `POST /v1/chats/{chat_id}/file-confirm` 后工具原地续跑 | | `compaction_notice` | 上一轮结束后已生成新的上下文压缩检查点 | `chat_id`, `context_compaction`(覆盖边界、摘要基线 token 数) | @@ -81,6 +83,12 @@ SSE follower:chat_run_executor.follow_run_as_sse() 流以 `data: [DONE]` 结束。示例帧: ``` +data: {"type":"tool_call_start","tool_name":"internet_search","tool_display_name":"联网搜索","tool_id":"call_abc"} + +data: {"type":"tool_call_delta","tool_name":"internet_search","arguments_delta":"{\"query\":\"北京 集成","tool_id":"call_abc"} + +data: {"type":"tool_call_delta","tool_name":"internet_search","arguments_delta":"电路 产业\"}","tool_id":"call_abc"} + data: {"type":"tool_call","tool_name":"internet_search","tool_display_name":"联网搜索","tool_args":{"query":"北京 集成电路 产业"},"tool_id":"call_abc"} data: {"type":"tool_result","tool_name":"internet_search","result":{...},"tool_id":"call_abc","citations":[{"id":"e1","title":"...","url":"...","snippet":"...","source_type":"internet","item_index":0}]} diff --git a/src/backend/api/routes/v1/chats.py b/src/backend/api/routes/v1/chats.py index 26c871b2..07e729f2 100644 --- a/src/backend/api/routes/v1/chats.py +++ b/src/backend/api/routes/v1/chats.py @@ -58,7 +58,9 @@ # the route handlers below stay unchanged. from core.chat.tool_log import ( # noqa: E402 build_thinking_event, + build_tool_call_delta_event, build_tool_call_event, + build_tool_call_start_event, build_tool_result_event, ) from core.services.artifact_service import persist_artifacts as _persist_artifacts # noqa: E402 @@ -1351,6 +1353,13 @@ def _flush_thinking() -> None: for _tc in tool_calls_log: _tc.setdefault("content_offset", len(full_response)) yield f"data: {json.dumps(_tc_evt, ensure_ascii=False)}\n\n" + elif chunk_type == "tool_call_start": + _flush_thinking() + _ts_evt = build_tool_call_start_event(chunk, chat_id) + yield f"data: {json.dumps(_ts_evt, ensure_ascii=False)}\n\n" + elif chunk_type == "tool_call_delta": + _td_evt = build_tool_call_delta_event(chunk, chat_id) + yield f"data: {json.dumps(_td_evt, ensure_ascii=False)}\n\n" elif chunk_type == "tool_result": _tr_evt = build_tool_result_event(chunk, chat_id, tool_calls_log) yield f"data: {json.dumps(_tr_evt, ensure_ascii=False)}\n\n" diff --git a/src/backend/api/routes/v1/integrations.py b/src/backend/api/routes/v1/integrations.py index 755aaae2..2bb3f1f6 100644 --- a/src/backend/api/routes/v1/integrations.py +++ b/src/backend/api/routes/v1/integrations.py @@ -122,9 +122,11 @@ class _YidaPollRequest(BaseModel): @router.get("/yida/status", summary="查询宜搭连接状态") async def yida_status( + probe: bool = Query(False, description="true 时向宜搭发起只读请求,实时核对登录态"), user: UserContext = Depends(get_current_user), ): - data = YidaService().get_status(str(user.user_id)) + svc = YidaService() + data = await svc.probe_status(str(user.user_id)) if probe else svc.get_status(str(user.user_id)) return success_response(data=data) diff --git a/src/backend/api/routes/v1/loops.py b/src/backend/api/routes/v1/loops.py index 937d32e1..96b79ce0 100644 --- a/src/backend/api/routes/v1/loops.py +++ b/src/backend/api/routes/v1/loops.py @@ -39,9 +39,12 @@ class GoalSpecIn(BaseModel): class BudgetIn(BaseModel): - max_iters: int = 50 - max_wall_clock_s: float = 6 * 3600.0 - max_tokens: int = 10_000_000 + # 预算默认不限(<=0 = 不设上限):循环的停止条件是「账本全部通过 / 停滞无解 / + # 用户取消」,能完成任务优先于省预算;防失控由 LOOP_HARD_MAX_ITERS 硬后备兜底。 + # 显式传正数仍然生效。 + max_iters: int = 0 + max_wall_clock_s: float = 0.0 + max_tokens: int = 0 class CreateLoopReq(BaseModel): @@ -60,6 +63,10 @@ class CreateLoopReq(BaseModel): class StartLoopReq(BaseModel): model_name: Optional[str] = None + # 用户在会话里选定的模型供应商(与普通聊天同源)——worker 跟随它,不再永远默认模型。 + model_provider_id: Optional[str] = None + # 评审/规划模型:不传则走「模型管理 → 角色分配 → 自主循环评审与规划(loop_reviewer)」, + # 未配置角色再回落 main_agent。 evaluator_model: Optional[str] = None worker_max_iters: int = 15 hitl_enabled: bool = False @@ -168,11 +175,31 @@ async def _launch_loop(loop_id: str, req: StartLoopReq, db: Session, user: UserC loop.chat_id = chat_id db.commit() + # 续跑不丢参:请求缺省的字段回读该 loop 持久化的启动参数(上次 start 存档), + # 崩溃/重启后的「继续」不再悄悄降级到默认模型/默认评审模型/默认轮数。 + saved = LoopService(db).get_start_params(loop_id) if is_resume else {} + model_name = req.model_name or saved.get("model_name") + evaluator_model = req.evaluator_model or saved.get("evaluator_model") + raw_provider_id = req.model_provider_id or saved.get("model_provider_id") + worker_max_iters = req.worker_max_iters if req.worker_max_iters != 15 else int( + saved.get("worker_max_iters") or req.worker_max_iters + ) + hitl_enabled = req.hitl_enabled or bool(saved.get("hitl_enabled")) + + # 与普通聊天同一套模型选择权限闸:未开放切换/不在可选列表的 provider 一律忽略。 + from core.services.user_model_selection import resolve_user_model_provider_id + + model_provider_id = resolve_user_model_provider_id( + db, raw_provider_id, user_id=user.user_id + ) + # chat_mode is the single source of truth for the thinking level; the # enable_thinking bool is only a legacy-client fallback. chat_mode = (req.chat_mode or "").strip().lower() or None if chat_mode not in (None, "turbo", "fast", "medium", "high", "max"): chat_mode = None + if chat_mode is None and is_resume: + chat_mode = (saved.get("chat_mode") or "").strip().lower() or None enable_thinking = (chat_mode not in ("fast", "turbo")) if chat_mode else req.enable_thinking # The project the loop is bound to (stored in metadata at creation) — the # worker/reviewer scope to the project folder based on it. @@ -183,10 +210,11 @@ async def _launch_loop(loop_id: str, req: StartLoopReq, db: Session, user: UserC user_id=user.user_id, goal_spec=loop.goal_spec or {}, budget=loop.budget or {}, - model_name=req.model_name, - evaluator_model=req.evaluator_model, - worker_max_iters=req.worker_max_iters, - hitl_enabled=req.hitl_enabled, + model_name=model_name, + model_provider_id=model_provider_id, + evaluator_model=evaluator_model, + worker_max_iters=worker_max_iters, + hitl_enabled=hitl_enabled, enable_thinking=enable_thinking, chat_mode=chat_mode, is_resume=is_resume, @@ -227,6 +255,30 @@ async def resume_loop( return await _launch_loop(loop_id, req, db, user, is_resume=True) +class SteerLoopReq(BaseModel): + message: str + + +@router.post("/{loop_id}/steer", summary="运行中追加指令(下一轮 worker 开工前生效)") +async def steer_loop( + loop_id: str, + req: SteerLoopReq, + db: Session = Depends(get_db), + user: UserContext = Depends(get_current_user), +): + """把一条用户指令排进 loop 的 steering 队列;driver 每轮开工前取走并以最高优先级 + 注入 worker prompt——不用取消重来就能中途转向(对齐 Codex /goal 的 steer 能力)。""" + svc = LoopService(db) + loop = svc.get_loop(loop_id, user_id=user.user_id) + if not loop: + raise HTTPException(status_code=404, detail="loop not found") + if loop.status not in ("running", "created", "interrupted", "awaiting_human"): + raise HTTPException(status_code=400, detail=f"loop 已终态({loop.status}),无法追加指令") + if not svc.push_steering(loop_id, req.message): + raise HTTPException(status_code=400, detail="指令为空") + return success_response({"queued": True}) + + @router.post("/{loop_id}/cancel", summary="取消循环") async def cancel_loop( loop_id: str, @@ -235,10 +287,19 @@ async def cancel_loop( ): # Normalize chat_id consistently with _launch_loop: prefer loop.chat_id, # otherwise loopchat_{id}. - loop = LoopService(db).get_loop(loop_id, user_id=user.user_id) - chat_id = (loop.chat_id if loop else None) or f"loopchat_{loop_id}" + svc = LoopService(db) + loop = svc.get_loop(loop_id, user_id=user.user_id) + if not loop: + raise HTTPException(status_code=404, detail="loop not found") + chat_id = loop.chat_id or f"loopchat_{loop_id}" run = chat_run_executor.get_active_run_for_chat(chat_id) if not run: - raise HTTPException(status_code=404, detail="no active run for loop") + # 无活跃 run(崩溃遗留/已中断):把 loop 状态归位而不是 404——历史缺陷是 + # 僵尸 running 行永远取消不掉。 + if loop.status in ("running", "interrupted", "created", "awaiting_human"): + loop.status = "cancelled" + db.commit() + return success_response({"cancelled": True, "note": "无活跃任务,已直接归位为 cancelled"}) + return success_response({"cancelled": False, "status": loop.status}) ok = await chat_run_executor.cancel_run(run.run_id, user_id=user.user_id) return success_response({"cancelled": ok}) diff --git a/src/backend/core/chat/tool_log.py b/src/backend/core/chat/tool_log.py index f61c544d..8ee97d70 100644 --- a/src/backend/core/chat/tool_log.py +++ b/src/backend/core/chat/tool_log.py @@ -26,6 +26,39 @@ def build_thinking_event(chunk: dict, chat_id: str) -> Dict[str, Any]: return evt +def build_tool_call_start_event(chunk: dict, chat_id: str) -> Dict[str, Any]: + """Build the transient event that opens one tool card by stable id. + + Start/delta events are transport state only. They intentionally do not + mutate ``tool_calls_log``; the completed ``tool_call`` remains the single + persisted source of truth. + """ + evt: Dict[str, Any] = { + "type": "tool_call_start", + "tool_name": chunk.get("tool_name"), + "tool_display_name": chunk.get("tool_display_name"), + "tool_id": chunk.get("tool_id"), + "chat_id": chat_id, + } + if chunk.get("scope"): + evt["scope"] = chunk["scope"] + return evt + + +def build_tool_call_delta_event(chunk: dict, chat_id: str) -> Dict[str, Any]: + """Build one incremental JSON-argument fragment for an open tool card.""" + evt: Dict[str, Any] = { + "type": "tool_call_delta", + "tool_name": chunk.get("tool_name"), + "tool_id": chunk.get("tool_id"), + "arguments_delta": chunk.get("arguments_delta", ""), + "chat_id": chat_id, + } + if chunk.get("scope"): + evt["scope"] = chunk["scope"] + return evt + + def build_tool_call_event(chunk: dict, chat_id: str, tool_calls_log: list) -> Dict[str, Any]: """Build the ``tool_call`` SSE event and upsert it into ``tool_calls_log``. diff --git a/src/backend/core/db/model_repository.py b/src/backend/core/db/model_repository.py index ae3d9b47..dead7599 100644 --- a/src/backend/core/db/model_repository.py +++ b/src/backend/core/db/model_repository.py @@ -23,6 +23,8 @@ "chart": {"label": "图表代码生成", "type": "chat"}, "plan_agent": {"label": "计划模式推理", "type": "chat"}, "code_exec": {"label": "代码执行推理", "type": "chat"}, + # 自主循环的评审员/规划器共用此角色(后台模型管理页可独立指定;未配置时回落 main_agent)。 + "loop_reviewer": {"label": "自主循环评审与规划", "type": "chat"}, } diff --git a/src/backend/core/db/models/agent.py b/src/backend/core/db/models/agent.py index 434cc42c..5ebb564f 100644 --- a/src/backend/core/db/models/agent.py +++ b/src/backend/core/db/models/agent.py @@ -263,7 +263,7 @@ class AgentLoop(Base): __table_args__ = ( CheckConstraint( "status IN ('created','running','paused','awaiting_human','completed'," - "'budget_exhausted','failed','cancelled')", + "'budget_exhausted','failed','cancelled','interrupted')", name="agent_loops_status_check", ), Index("idx_agent_loops_user_id", "user_id"), diff --git a/src/backend/core/evolution/agent_profile.py b/src/backend/core/evolution/agent_profile.py index 350b09d8..6afcbf3f 100644 --- a/src/backend/core/evolution/agent_profile.py +++ b/src/backend/core/evolution/agent_profile.py @@ -391,6 +391,58 @@ def pick_profile( return ranked[0][2] +def _profile_from_row(row: Any) -> AgentProfile: + """The profile a stored row describes, with the columns believed over payload. + + ``profile_id`` / ``version`` / ``task_types`` / ``scope`` exist as real + columns *and* inside ``payload`` (:meth:`AgentProfile.to_dict` writes them), + so for anything published through :mod:`core.evolution.activation` the two + agree and this is a no-op. They stop agreeing for a row written by hand or + by a seeding script, and reading only ``payload`` then fails open in the + worst possible direction: a missing ``task_types`` parses as ``[]``, which + :meth:`AgentProfile.applies_to` treats as *every* task type, and a missing + ``scope`` parses as ``{}``, which :func:`_scope_rank` treats as *every* + subject. A profile published for one task type silently governs every run — + reported under the id ``builtin``, because that is what + :meth:`AgentProfile.from_dict` falls back to, so the log line naming the + culprit names the deployment default instead. + + The two narrowing fields resolve to whichever side is non-empty rather than + to the column unconditionally: an empty column against a populated payload + is the same widening failure with the sides swapped. When both are populated + and disagree the column wins — it is what the activation uniqueness check and + the console read — and the disagreement is logged, because it means something + wrote this row outside the activation path. + """ + raw = dict(row.payload or {}) + + # Identity: the column is the primary key and NOT NULL, so it is always the + # more trustworthy of the two, and a wrong id here is what makes a bad + # profile untraceable in the logs. + raw["profile_id"] = row.profile_id + raw["version"] = row.version + + col_types = [str(t) for t in (row.task_types or [])] + payload_types = [str(t) for t in (raw.get("task_types") or [])] + if col_types and payload_types and set(col_types) != set(payload_types): + logger.warning( + "[agent-profile] %s: task_types column %s != payload %s, using column", + row.profile_id, col_types, payload_types, + ) + raw["task_types"] = col_types or payload_types + + col_scope = dict(row.scope or {}) + payload_scope = dict(raw.get("scope") or {}) + if col_scope and payload_scope and col_scope != payload_scope: + logger.warning( + "[agent-profile] %s: scope column %s != payload %s, using column", + row.profile_id, col_scope, payload_scope, + ) + raw["scope"] = col_scope or payload_scope + + return AgentProfile.from_dict(raw) + + def load_active_profile( *, task_type: str = "chat", @@ -416,7 +468,7 @@ def load_active_profile( .order_by(EvolutionAgentProfile.updated_at.desc()) .all() ) - profiles = [AgentProfile.from_dict(row.payload or {}) for row in rows] + profiles = [_profile_from_row(row) for row in rows] except Exception as exc: # noqa: BLE001 logger.warning("[agent-profile] load failed, using built-in: %s", exc) return builtin_profile() diff --git a/src/backend/core/llm/agent_factory.py b/src/backend/core/llm/agent_factory.py index 35f90c34..dd551706 100644 --- a/src/backend/core/llm/agent_factory.py +++ b/src/backend/core/llm/agent_factory.py @@ -478,6 +478,10 @@ async def create_agent_executor( isolated: bool = False, max_iters: Optional[int] = None, plan_mode: bool = False, + # model_role: 指定按「模型管理 → 角色分配」的哪个角色解析默认模型(如 + # "loop_reviewer")。优先级低于用户显式选择的 model_provider_id/model_name, + # 高于 main_agent 兜底;plan_mode=True 等价于 model_role="plan_agent"。 + model_role: Optional[str] = None, batch_mode: bool = False, # top_level_chat: whether this construction is a "top-level interactive main # conversation capable of hosting plan mode" — astream_chat_workflow passes @@ -571,6 +575,8 @@ def _elapsed(): from core.services.system_config import ( turbo_manual_invoke_enabled, turbo_mcp_server_ids, + turbo_plugin_ids, + turbo_skill_ids, ) if not turbo_manual_invoke_enabled(): @@ -578,11 +584,30 @@ def _elapsed(): turbo_explicit_skill_ids = None turbo_explicit_mcp_ids = None visible_subagents = None - # Only explicitly summoned skills survive; without a summon the agent - # carries no skills at all (and no skill list enters the prompt). - enabled_skill_ids = [ - s for s in (turbo_explicit_skill_ids or []) if isinstance(s, str) and s.strip() - ] + # Admin-configured turbo plugins, expanded into their component skills + + # MCPs. A plugin is the installable/removable unit — some capabilities + # (e.g. a crawler, a ticket system) ship only as a plugin and have no + # loose MCP row to pick, so without this they were unreachable in turbo. + turbo_plugin_skill_ids, turbo_plugin_mcp_ids = _expand_plugin_bindings( + list(turbo_plugin_ids()) + ) + # Skills in turbo = admin-configured set (turbo.skill_ids + those bundled + # with a configured plugin) + the ones explicitly summoned this turn. + # With none of the three the agent carries no skills at all (and no skill + # list enters the prompt) — the original quick-lookup contract. + enabled_skill_ids = list( + dict.fromkeys( + [ + *turbo_skill_ids(), + *[s for s in turbo_plugin_skill_ids if isinstance(s, str) and s.strip()], + *[ + s + for s in (turbo_explicit_skill_ids or []) + if isinstance(s, str) and s.strip() + ], + ] + ) + ) top_level_chat = False read_only = True allow_bash = False @@ -594,6 +619,7 @@ def _elapsed(): dict.fromkeys( [ *sorted(turbo_mcp_server_ids()), + *[m for m in turbo_plugin_mcp_ids if isinstance(m, str) and m.strip()], *[ m for m in (turbo_explicit_mcp_ids or []) @@ -1539,7 +1565,7 @@ def _build_toolkit() -> Toolkit: ) except Exception as exc: _log.warning("[factory] selected model resolve failed: %s, falling back", exc) - _mode_role = "plan_agent" if plan_mode else None + _mode_role = model_role or ("plan_agent" if plan_mode else None) if default_model is None and _mode_role: try: from core.services.model_config import ModelConfigService diff --git a/src/backend/core/llm/subagent_tool.py b/src/backend/core/llm/subagent_tool.py index 87a4b8b2..4b32a0d0 100644 --- a/src/backend/core/llm/subagent_tool.py +++ b/src/backend/core/llm/subagent_tool.py @@ -28,6 +28,9 @@ # a single task — avoiding the cross-task RuntimeError. _subagent_pool = ThreadPoolExecutor(max_workers=8, thread_name_prefix="subagent") +_TOOL_CALL_DELTA_FLUSH_INTERVAL_S = 0.05 +_TOOL_CALL_DELTA_FLUSH_CHARS = 256 + def _shared_ontology_runtime(agent_ref: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: """Return the parent request's ontology runtime without copying it. @@ -55,6 +58,8 @@ class _SubMapper: def __init__(self) -> None: self._names: Dict[str, str] = {} # tool_id → name self._args: Dict[str, str] = {} # tool_id → accumulated args JSON string + self._arg_emit_buf: Dict[str, str] = {} # tool_id → not-yet-emitted delta + self._arg_last_emit: Dict[str, float] = {} self._results: Dict[str, str] = {} # tool_id → accumulated result text # Inline thinking () splitting: the deepseek/qwen family models # commonly used by sub-agents inline the reasoning chain in the body deltas, and @@ -118,6 +123,8 @@ def feed(self, ev: Any) -> List[Dict[str, Any]]: name = getattr(ev, "tool_call_name", "") or "unknown" self._names[tid] = name self._args[tid] = "" + self._arg_emit_buf[tid] = "" + self._arg_last_emit[tid] = time.monotonic() out.append( { "sub_type": "tool_call", @@ -130,12 +137,43 @@ def feed(self, ev: Any) -> List[Dict[str, Any]]: elif nm == "ToolCallDeltaEvent": tid = getattr(ev, "tool_call_id", "") or "" - self._args[tid] = self._args.get(tid, "") + (getattr(ev, "delta", "") or "") + delta = getattr(ev, "delta", "") or "" + if delta: + self._args[tid] = self._args.get(tid, "") + delta + pending_delta = self._arg_emit_buf.get(tid, "") + delta + self._arg_emit_buf[tid] = pending_delta + now = time.monotonic() + last_emit = self._arg_last_emit.get(tid, now) + if ( + len(pending_delta) >= _TOOL_CALL_DELTA_FLUSH_CHARS + or now - last_emit >= _TOOL_CALL_DELTA_FLUSH_INTERVAL_S + ): + self._arg_emit_buf[tid] = "" + self._arg_last_emit[tid] = now + out.append( + { + "sub_type": "tool_call_delta", + "tool_id": tid, + "tool_name": self._names.get(tid, "unknown"), + "arguments_delta": pending_delta, + } + ) elif nm == "ToolCallEndEvent": tid = getattr(ev, "tool_call_id", "") or "" name = self._names.get(tid, "unknown") - args_str = self._args.get(tid, "") + args_str = self._args.pop(tid, "") + pending_delta = self._arg_emit_buf.pop(tid, "") + self._arg_last_emit.pop(tid, None) + if pending_delta: + out.append( + { + "sub_type": "tool_call_delta", + "tool_id": tid, + "tool_name": name, + "arguments_delta": pending_delta, + } + ) try: args = json.loads(args_str) if args_str else {} except json.JSONDecodeError: @@ -158,6 +196,7 @@ def feed(self, ev: Any) -> List[Dict[str, Any]]: tid = getattr(ev, "tool_call_id", "") or "" content = self._results.pop(tid, "") name = getattr(ev, "tool_call_name", "") or self._names.get(tid, "unknown") + self._names.pop(tid, None) state = str(getattr(ev, "state", "") or "") out.append( { @@ -288,12 +327,9 @@ async def _inner() -> str: # to sub-steps and bypass-forwarded via emit. final_msg = None mapper = _SubMapper() - # Throttled liveness: during long tool-call-argument generation the - # sub-agent's upstream events all buffer inside _SubMapper (ToolCallDelta / - # ToolResultTextDelta / unclassified thinking) and emit is never called — - # the parent run's inactivity watchdog then saw pure silence and killed a - # healthy run mid-generation (the main-path StreamingAgent got this fix as - # ("model_progress", None); nested subagents were the known remaining gap). + # Throttled liveness remains useful while small argument fragments + # are waiting for a batch flush, tool results are being accumulated, + # or leading text is still being classified as thinking/content. _last_progress = time.monotonic() async for chunk in agent._reply(inputs=user_msg): if isinstance(chunk, Msg): diff --git a/src/backend/core/services/loop_service.py b/src/backend/core/services/loop_service.py index 841b0eb6..277bdfa5 100644 --- a/src/backend/core/services/loop_service.py +++ b/src/backend/core/services/loop_service.py @@ -80,7 +80,60 @@ def list_iterations(self, loop_id: str) -> List[LoopIteration]: .all() ) + # ── 启动参数持久化(续跑不丢参:模型/评审模型/轮数/思考档位跟 loop 走,而非跟请求走) ── + def save_start_params(self, loop_id: str, params: Dict[str, Any]) -> None: + loop = self.get_loop(loop_id) + if not loop: + return + meta = dict(loop.extra_data or {}) + meta["start_params"] = {k: v for k, v in params.items() if v not in (None, "")} + loop.extra_data = meta + loop.updated_at = _now() + self.db.commit() + + def get_start_params(self, loop_id: str) -> Dict[str, Any]: + loop = self.get_loop(loop_id) + params = ((loop.extra_data or {}).get("start_params") if loop else None) or {} + return dict(params) if isinstance(params, dict) else {} + + # ── steering 队列(用户运行中追加指令;driver 每轮开工前取走并清空) ────────── + def push_steering(self, loop_id: str, message: str) -> bool: + loop = self.get_loop(loop_id) + if not loop or not message.strip(): + return False + meta = dict(loop.extra_data or {}) + queue = list(meta.get("steering") or []) + queue.append(message.strip()[:2000]) + meta["steering"] = queue[-10:] # 只保留最近 10 条,防队列无限膨胀 + loop.extra_data = meta + loop.updated_at = _now() + self.db.commit() + return True + + def consume_steering(self, loop_id: str) -> List[str]: + loop = self.get_loop(loop_id) + if not loop: + return [] + meta = dict(loop.extra_data or {}) + queue = list(meta.get("steering") or []) + if queue: + meta["steering"] = [] + loop.extra_data = meta + self.db.commit() + return queue + # ── State transitions / audit ─────────────────────────────────────────────── + def mark_interrupted(self, loop_id: str, reason: str = "") -> None: + """进程重启后对账:running 但已无活跃 run 的 loop 归位为 interrupted(可续跑)。""" + loop = self.get_loop(loop_id) + if not loop or loop.status != "running": + return + loop.status = "interrupted" + if reason: + loop.result_summary = reason[:2000] + loop.updated_at = _now() + self.db.commit() + def mark_running(self, loop_id: str, *, workspace_session: Optional[str] = None) -> None: loop = self.get_loop(loop_id) if not loop: diff --git a/src/backend/core/services/system_config.py b/src/backend/core/services/system_config.py index 4d634537..dc6a2058 100644 --- a/src/backend/core/services/system_config.py +++ b/src/backend/core/services/system_config.py @@ -243,6 +243,26 @@ "turbo", False, ), + ( + "turbo.skill_ids", + "", + "极速模式可用技能", + "极速模式下装配的技能集合(逗号分隔的 skill id)。" + "极速模式没有 bash / 沙箱 / 文件写入,选纯文本类技能(写作模板、话术规范等)效果最好;" + "需要代码执行的技能只会被读到说明、无法执行。同样不受「能力目录」启停影响。", + "turbo", + False, + ), + ( + "turbo.plugin_ids", + "", + "极速模式可用插件", + "极速模式下装配的插件集合(逗号分隔的 install_id)。" + "插件是「技能 + 工具」的能力包,选中后其全部工具与技能一并在极速模式生效;" + "与上面的 MCP 工具集合合并去重,同样不受「能力目录」启停影响。", + "turbo", + False, + ), ( "turbo.manual_invoke_enable", "true", @@ -528,6 +548,34 @@ def turbo_mcp_server_ids() -> frozenset[str]: return ids or DEFAULT_TURBO_MCP_SERVER_IDS +def turbo_skill_ids() -> tuple[str, ...]: + """极速模式装配的技能集合(``AdminSkill.skill_id`` / 内置技能 id 列表)。 + + 控制源 = Config 管理台「系统配置 → 极速模式」的 ``turbo.skill_ids``(逗号 + 分隔)。与用户本轮显式呼唤的技能合并去重。默认空 —— 不选就没有技能, + 极速模式的系统提示词里也就不出现技能清单。 + """ + try: + raw = SystemConfigService.get_instance().get("turbo.skill_ids", "") or "" + except Exception: # noqa: BLE001 — 配置层异常时按「没配技能」处理 + return () + return tuple(dict.fromkeys(part.strip() for part in str(raw).split(",") if part.strip())) + + +def turbo_plugin_ids() -> tuple[str, ...]: + """极速模式装配的插件集合(``InstalledPlugin.install_id`` 列表)。 + + 控制源 = Config 管理台「系统配置 → 极速模式」的 ``turbo.plugin_ids``(逗号 + 分隔)。插件是「技能 + 工具」的能力包,装配时由 agent_factory 展开成其组件 + 技能 / MCP,与 ``turbo.mcp_server_ids`` 合并去重。默认空 —— 不选就没有插件。 + """ + try: + raw = SystemConfigService.get_instance().get("turbo.plugin_ids", "") or "" + except Exception: # noqa: BLE001 — 配置层异常时按「没配插件」处理 + return () + return tuple(dict.fromkeys(part.strip() for part in str(raw).split(",") if part.strip())) + + def turbo_manual_invoke_enabled() -> bool: """极速模式下是否允许显式呼唤(斜杠技能 / @子智能体 / 插件)临时装配。""" try: diff --git a/src/backend/core/services/yida_service.py b/src/backend/core/services/yida_service.py index f2bc569d..ebe02b8a 100644 --- a/src/backend/core/services/yida_service.py +++ b/src/backend/core/services/yida_service.py @@ -33,6 +33,7 @@ import json import logging import re +import shlex import time from pathlib import Path from typing import Any, Dict, Optional @@ -59,6 +60,59 @@ _PENDING: Dict[str, Dict[str, Any]] = {} _PENDING_TTL_S = 10 * 60 +# ``openyida login --check-only`` / ``agent-capabilities`` only validate that +# the local cookie cache is structurally complete. An expired server-side +# session therefore still looks connected. The status-panel probe uses the +# same read-only app-list endpoint as ``openyida app-list``, but calls the low +# level HTTP helper directly so an invalid cookie is reported instead of +# triggering openyida's automatic browser-login fallback. The script prints +# only a three-state verdict and never prints credentials or response data. +_PROBE_SCRIPT = """ +const utils = require('/opt/node/v22.2.0/lib/node_modules/openyida/lib/core/utils'); +(async () => { + try { + const cookieData = utils.loadCookieData(); + const cookies = cookieData && Array.isArray(cookieData.cookies) ? cookieData.cookies : []; + if (!cookieData || cookies.length === 0) { + process.stdout.write(JSON.stringify({verdict: 'invalid'})); + return; + } + const info = utils.extractInfoFromCookies(cookies); + const csrfToken = cookieData.csrf_token || cookieData.csrfToken || + cookieData._csrf_token || info.csrfToken || ''; + const userId = cookieData.user_id || cookieData.userId || + cookieData.staffId || info.userId || ''; + if (!csrfToken || !userId) { + process.stdout.write(JSON.stringify({verdict: 'invalid'})); + return; + } + const result = await utils.httpGet( + utils.resolveBaseUrl(cookieData), + '/query/app/getAppList.json', + { + _api: 'nattyFetch', + _mock: 'false', + pageIndex: 1, + pageSize: 1, + creator: userId, + _csrf_token: csrfToken, + _stamp: Date.now(), + }, + cookies, + {silentStatus: true}, + ); + const verdict = result && (result.__needLogin || result.__csrfExpired) + ? 'invalid' + : (result && result.success === true ? 'valid' : 'unknown'); + process.stdout.write(JSON.stringify({verdict})); + } catch (_) { + process.stdout.write(JSON.stringify({verdict: 'unknown'})); + } +})(); +""".strip() +_PROBE_COMMAND = f"node -e {shlex.quote(_PROBE_SCRIPT)}" +_EXPIRED_ERROR = "宜搭登录态已失效,请重新扫码连接" + def extract_result_json(stdout: str) -> Optional[Dict[str, Any]]: """Extract the result JSON from CLI stdout. The agent-family commands emit a @@ -94,6 +148,16 @@ def _cookie_files(ws: Path) -> list[Path]: return sorted((ws / ".cache").glob("cookies*.json")) if (ws / ".cache").is_dir() else [] +def _latest_cookie_mtime_ns(ws: Path) -> int: + latest = 0 + for cookie_file in _cookie_files(ws): + try: + latest = max(latest, cookie_file.stat().st_mtime_ns) + except OSError: + continue + return latest + + def _connection_meta_path(user_id: str) -> Path: return _host_workspace_dir(user_id).parent / "connection.json" @@ -220,17 +284,84 @@ def get_status(self, user_id: str) -> Dict[str, Any]: _PENDING.pop(uid, None) else: return self._pending_response(pending) - if _cookie_files(_host_workspace_dir(uid)): + workspace = _host_workspace_dir(uid) + cookie_mtime_ns = _latest_cookie_mtime_ns(workspace) + if cookie_mtime_ns: meta = _load_meta(uid) + invalidated_cookie_mtime_ns = int(meta.get("invalidated_cookie_mtime_ns") or 0) + if ( + meta.get("status") == "disconnected" + and invalidated_cookie_mtime_ns >= cookie_mtime_ns + ): + return { + "status": "disconnected", + "error": meta.get("last_error") or _EXPIRED_ERROR, + "last_verified_at": meta.get("last_verified_at"), + } return { "status": "connected", "corp_id": meta.get("corp_id"), "yida_user_id": meta.get("user_id"), "base_url": meta.get("base_url"), "connected_at": meta.get("connected_at"), + "last_verified_at": meta.get("last_verified_at"), } return {"status": "disconnected"} + async def probe_status(self, user_id: str) -> Dict[str, Any]: + """Reconcile the local cookie-file state with a real, read-only Yida request. + + ``valid`` refreshes verification metadata; ``invalid`` records which + exact cookie version was rejected so ordinary status reads also become + disconnected. A later QR login overwrites the cookie and its newer + mtime automatically clears that invalidation. ``unknown`` (network, + timeout, CLI/image mismatch) keeps the local state unchanged to avoid + logging users out because of a transient failure. + """ + uid = safe_user_id(user_id) + if not uid: + return {"status": "disconnected"} + local_status = self.get_status(uid) + if local_status.get("status") in {"pending", "corp_selection"}: + return local_status + + workspace = _host_workspace_dir(uid) + cookie_mtime_ns = _latest_cookie_mtime_ns(workspace) + if not cookie_mtime_ns: + return {"status": "disconnected"} + + stdout, rc = await self._run_in_sandbox(uid, _PROBE_COMMAND, timeout=35) + data = extract_result_json(stdout) + verdict = data.get("verdict") if data and rc == 0 else "unknown" + now = int(time.time()) + meta = _load_meta(uid) + if verdict == "valid": + _save_meta( + uid, + { + **meta, + "status": "connected", + "last_verified_at": now, + "last_error": None, + "invalidated_cookie_mtime_ns": 0, + }, + ) + elif verdict == "invalid": + _save_meta( + uid, + { + **meta, + "status": "disconnected", + "last_verified_at": now, + "last_error": _EXPIRED_ERROR, + "invalidated_cookie_mtime_ns": cookie_mtime_ns, + }, + ) + logger.info("[yida] 真实探活确认登录态失效 user=%s", uid) + else: + logger.info("[yida] 真实探活无法判定,保留本地状态 user=%s rc=%s", uid, rc) + return self.get_status(uid) + # ── Three-stage login ─────────────────────────────────────────────── async def start_login(self, user_id: str) -> Dict[str, Any]: diff --git a/src/backend/orchestration/autonomous_loop.py b/src/backend/orchestration/autonomous_loop.py index c819d3db..0a66984d 100644 --- a/src/backend/orchestration/autonomous_loop.py +++ b/src/backend/orchestration/autonomous_loop.py @@ -40,18 +40,74 @@ from core.infra.logging import get_logger from orchestration.loop_evaluator import ( + CONTINUE, DONE, NEED_HUMAN, GoalSpec, decompose_requirements, extract_acceptance_criteria, ) +from orchestration.loop_planner import ( + plan_requirements, + replan_remaining, + scout_workspace, +) from orchestration.subagents.loop_reviewer import review_requirement logger = get_logger(__name__) EmitFn = Callable[[Dict[str, Any]], Awaitable[None]] CancelFn = Callable[[], bool] +SteeringFn = Callable[[], List[str]] + + +def _env_int(name: str, default: int, *, floor: int = 1) -> int: + import os + + try: + return max(floor, int(os.getenv(name, str(default)))) + except ValueError: + return default + + +# 去预算化后的防死循环硬后备(不是预算——预算默认不设;这是「循环失控」的最后保险丝, +# 远大于任何正常任务的轮数)。连续基础设施故障轮的熔断阈值同理。 +def _hard_max_iters() -> int: + return _env_int("LOOP_HARD_MAX_ITERS", 500) + + +def _max_consecutive_infra() -> int: + return _env_int("LOOP_MAX_CONSECUTIVE_INFRA", 6) + + +def _max_replans() -> int: + return _env_int("LOOP_MAX_REPLANS", 2, floor=0) + + +def _wrapup_enabled() -> bool: + import os + + return os.getenv("LOOP_WRAPUP", "true").strip().lower() in ("1", "true", "yes") + + +def _build_wrapup_prompt(objective: str, ledger: Dict[str, Any], in_project: bool) -> str: + done = [r for r in ledger["requirements"] if r.get("passes")] + undone = [r for r in ledger["requirements"] if not r.get("passes")] + where = "项目文件夹" if in_project else "/workspace" + return ( + "# 收尾交付(最后一轮,不再评审)\n" + f"自主任务到此收尾。总目标:\n{objective}\n\n" + "## 已完成并通过评审的需求\n" + + ("\n".join(f"- {r['id']}: {r['description']}" for r in done) or "(无)") + + "\n\n## 未完成的需求\n" + + ("\n".join(f"- {r['id']}: {r['description']}" for r in undone) or "(无)") + + f"\n\n## 你的任务\n1. 检查 {where} 里的现有成果,把**已完成部分**整合成对用户" + "可直接使用的交付形态(该合并的合并、该注册 artifact 的注册" + + ("、站点有改动就 publish_site 发新版" if in_project else "") + + ")。\n2. 用中文给用户写一段简明收尾说明:交付了什么、在哪里取用、" + "未完成的部分还差什么、建议下一步。**如实说明,不得声称未完成的已完成。**\n" + "3. 只整合与说明,不要开工做新需求。" + ) # Loop thresholds come from the active orchestration profile, resolved **per # run** and **per tenant** (core.evolution.policies projects the profile onto @@ -89,10 +145,14 @@ def _loop_tool_result_limit() -> int: @dataclass class LoopBudget: - max_iters: int = 50 - max_wall_clock_s: float = 6 * 3600.0 - max_tokens: int = 10_000_000 - max_subagents: int = 20 # reserved + """预算已降级为**可选约束**:字段 <=0 一律视为「不限」,且默认全部不限—— + 循环的停止条件回归「账本全部通过 / 停滞无解 / 用户取消」(能完成任务比省预算重要)。 + 显式传正数仍然生效(想限就限);防失控由 LOOP_HARD_MAX_ITERS 硬后备兜底。""" + + max_iters: int = 0 + max_wall_clock_s: float = 0.0 + max_tokens: int = 0 + max_subagents: int = 0 # reserved def snapshot(self) -> Dict[str, Any]: return asdict(self) @@ -178,6 +238,16 @@ async def _git_checkpoint(session_id: str, user_id: str, msg: str) -> Optional[s return out.strip() if code == 0 and out.strip() else None +async def _git_worktree_changed(session_id: str, user_id: str) -> bool: + """本轮工作区相对上个 checkpoint 是否真有改动(机检失败轮的客观推进信号)。""" + code, out, _ = await _sbx_exec( + f"cd {_WORKSPACE} && git rev-parse --git-dir >/dev/null 2>&1 || exit 42; " + "git status --porcelain 2>/dev/null | head -1", + session_id=session_id, user_id=user_id, + ) + return code == 0 and bool(out.strip()) + + async def _git_diff_stat(session_id: str, user_id: str) -> str: code, out, _ = await _sbx_exec( f"cd {_WORKSPACE} && git rev-parse --git-dir >/dev/null 2>&1 || exit 42; " @@ -188,14 +258,26 @@ async def _git_diff_stat(session_id: str, user_id: str) -> str: # ── Requirement ledger feature_list.json (driver-owned; worker may not delete or modify) ─────────────────── -def _new_ledger(objective: str, requirements: List[Dict[str, Any]]) -> Dict[str, Any]: +def _init_req_fields(requirements: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """补齐需求条目的运行时字段(新建账本与重规划后的新条目共用)。""" for r in requirements: r.setdefault("passes", False) r.setdefault("evidence", "") - r.setdefault("attempts", 0) # total iterations attempted for this item (strategy change / hard ceiling) - r.setdefault("stalls", 0) # consecutive rounds WITHOUT reviewer-affirmed material progress (stagnation cap) - r.setdefault("blocked", False) # stagnating at the stall cap (or runaway at the hard ceiling) → mark skipped, avoids a single-item infinite loop - return {"objective": objective, "iteration": 0, "requirements": requirements} + r.setdefault("attempts", 0) + r.setdefault("stalls", 0) + r.setdefault("blocked", False) + r.setdefault("check_cmd", "") + r.setdefault("last_feedback", "") + return requirements + + +def _new_ledger(objective: str, requirements: List[Dict[str, Any]]) -> Dict[str, Any]: + # 字段语义:attempts=总尝试轮数(防失控硬上限);stalls=连续无实质推进轮数 + # (停滞判定与停滞告警都看它);blocked=搁置;check_cmd=只读机检命令(driver + # 亲自执行,exit 0=客观达标,空=纯语义需求);last_feedback=本需求最近一次 + # 评审/机检反馈(重规划输入;不跨需求泄漏)。 + return {"objective": objective, "iteration": 0, + "requirements": _init_req_fields(requirements)} async def _read_ledger( @@ -292,6 +374,7 @@ async def _run_worker_iteration( session_id: str, user_id: str, model_name: Optional[str], + model_provider_id: Optional[str] = None, worker_max_iters: int, enable_thinking: bool, chat_mode: Optional[str], @@ -315,6 +398,7 @@ async def _run_worker_iteration( agent, clients = await create_agent_executor( current_user_id=user_id, model_name=model_name, + model_provider_id=model_provider_id, # 用户在会话里选定的模型跟随 loop(不再固定默认模型) sandbox_session_id=session_id, # key: same session → files persist across iterations project_ctx=project_ctx, # key: bind to the user-selected project (site source workspace) chat_id=chat_id, @@ -379,6 +463,25 @@ async def _run_worker_iteration( elif et == "thinking_delta": if emit: await emit({"type": "thinking", "delta": payload}) + elif et == "tool_call_start": + if emit: + await emit( + { + "type": "tool_call_start", + "tool_name": payload.get("name"), + "tool_id": payload.get("id"), + } + ) + elif et == "tool_call_delta": + if emit and payload.get("delta"): + await emit( + { + "type": "tool_call_delta", + "tool_name": payload.get("name"), + "tool_id": payload.get("id"), + "arguments_delta": payload.get("delta"), + } + ) elif et == "tool_call": tool_calls += 1 trace.append( @@ -479,6 +582,7 @@ def _build_requirement_prompt( feedback: str, strategy_change: bool, in_project: bool, + steering: Optional[List[str]] = None, ) -> str: """One requirement at a time: feed only the current requirement to the worker (Claude Code does one feature at a time).""" if in_project: @@ -509,9 +613,20 @@ def _build_requirement_prompt( f"\n## 🎯 本轮唯一目标:完成需求 {req['id']}\n{req['description']}\n\n" "**只做这一条**。不要提前做别的需求、不要改需求账本——把这一条扎实做到位、" "落进真实文件(会有一个独立评审员打开你产出的文件逐条核验,光声称做了没用)。" + + ( + f"\n本需求还有一条机检命令(driver 会亲自执行,退出码 0 才算数):\n" + f"`{req['check_cmd']}`\n完工前自己先跑一遍确认能过。" + if req.get("check_cmd") + else "" + ) ), workspace_note, ] + if steering: + parts.append( + "\n## 📣 用户临时指令(最高优先级,本轮必须遵循)\n" + + "\n".join(f"- {s}" for s in steering) + ) if handoff: parts.append(f"\n## 上一轮交接(git diff + 摘要)\n{handoff}") if feedback: @@ -562,6 +677,7 @@ async def run_autonomous_loop( goal_spec: GoalSpec, budget: LoopBudget, model_name: Optional[str] = None, + model_provider_id: Optional[str] = None, evaluator_model: Optional[str] = None, worker_max_iters: int = 15, session_id: Optional[str] = None, @@ -572,6 +688,7 @@ async def run_autonomous_loop( is_cancelled: Optional[CancelFn] = None, load_ledger: Optional[Callable[[], Optional[Dict[str, Any]]]] = None, save_ledger: Optional[Callable[[Dict[str, Any]], None]] = None, + poll_steering: Optional[SteeringFn] = None, project_ctx: Optional[Dict[str, Any]] = None, chat_id: Optional[str] = None, tenant_id: str = "default", @@ -620,6 +737,7 @@ async def run_autonomous_loop( feedback = "" tokens_spent = 0 seq = 0 + consecutive_infra = 0 # 连续零产出/异常轮计数(熔断用),健康轮清零 final_score: Optional[float] = None # Acceptance criteria: fed to the reviewer sub-agent to verify the real output item by item (extracted once before the run, stored in the ledger, reused on resume). criteria: List[str] = list(goal_spec.acceptance_criteria or []) @@ -671,10 +789,28 @@ async def _persist_ledger(led: Dict[str, Any]) -> None: for r in ledger["requirements"]]}) else: await _git_init(session, user_id) - reqs = await decompose_requirements( - goal_spec=goal_spec, model_name=evaluator_model or "fast", user_id=user_id, + # 规划器 v2:只读侦察员先摸真实工作区/项目 → 规划模型据实拆账本(含可选机检命令)。 + # 侦察/规划任一环节失败都退回旧 decompose 链路,绝不因规划升级而拖垮循环。 + survey = "" + try: + survey = await scout_workspace( + objective=goal_spec.objective, session_id=session, user_id=user_id, + project_ctx=project_ctx, chat_id=chat_id, model_name=evaluator_model, + ) + except Exception as exc: # noqa: BLE001 + logger.warning("[loop %s] scout failed: %s", loop_id, exc) + if survey: + await _emit(emit, {"type": "loop_scouted", "survey": survey[:800]}) + reqs = await plan_requirements( + goal_spec=goal_spec, survey=survey, model_name=evaluator_model, user_id=user_id, ) + if not reqs: + reqs = await decompose_requirements( + goal_spec=goal_spec, model_name=evaluator_model or "fast", user_id=user_id, + ) ledger = _new_ledger(goal_spec.objective, reqs) + if survey: + ledger["survey"] = survey # 存档给重规划用(重拆时无需再侦察一遍) await _persist_ledger(ledger) await _write_file( f"{_WORKSPACE}/PROGRESS.md", @@ -703,11 +839,15 @@ async def _persist_ledger(led: Dict[str, Any]) -> None: await _persist_ledger(ledger) def _budget_left() -> Optional[str]: - if seq >= budget.max_iters: + # 预算是可选约束:<=0 一律不限(默认)。「能完成任务」优先于省预算—— + # 唯一的无条件上限是防死循环的硬后备 LOOP_HARD_MAX_ITERS。 + if seq >= _hard_max_iters(): + return f"触发防失控硬后备({_hard_max_iters()} 轮)——请检查任务是否根本无法收敛" + if budget.max_iters > 0 and seq >= budget.max_iters: return f"达到最大迭代数 {budget.max_iters}" - if time.monotonic() - t0 >= budget.max_wall_clock_s: + if budget.max_wall_clock_s > 0 and time.monotonic() - t0 >= budget.max_wall_clock_s: return f"达到最大墙钟 {budget.max_wall_clock_s}s" - if tokens_spent >= budget.max_tokens: + if budget.max_tokens > 0 and tokens_spent >= budget.max_tokens: return f"达到 token 预算 {budget.max_tokens}" return None @@ -741,61 +881,123 @@ def _budget_left() -> Optional[str]: seq += 1 ledger["iteration"] = seq + # 用户临时指令(steer):每轮开工前取一次队列,注入本轮 worker prompt(最高优先级)。 + steering: List[str] = [] + if poll_steering: + try: + steering = [s for s in (poll_steering() or []) if str(s).strip()] + except Exception as exc: # noqa: BLE001 - steering 读取失败不拖垮循环 + logger.warning("[loop %s] poll_steering failed: %s", loop_id, exc) + if steering: + await _emit(emit, {"type": "loop_steering_consumed", "seq": seq, + "messages": [s[:200] for s in steering]}) await _emit(emit, {"type": "iteration_started", "seq": seq, "requirement_id": req["id"], "progress": _progress_frac(ledger)}) logger.info("[loop %s] iter %d req=%s (%s)", loop_id, seq, req["id"], _progress_frac(ledger)) # 1) Worker runs one iteration (fresh context, fed only the current requirement) - strategy_change = int(req.get("attempts", 0)) >= strategy_change_after + # 停滞告警看 stalls(连续无实质推进轮数)而非 attempts:健康推进多轮的大需求 + # (如 20 章正文逐章写)不能每轮被怂恿「换根本不同的方法」推翻半成品—— + # 这与 2026-08-10 的 progress/stall 修复必须同一口径。 + strategy_change = int(req.get("stalls", 0)) >= strategy_change_after prompt = _build_requirement_prompt( objective=goal_spec.objective, ledger=ledger, req=req, seq=seq, handoff=handoff, feedback=feedback, strategy_change=strategy_change, - in_project=in_project, - ) - work = await _run_worker_iteration( - prompt=prompt, session_id=session, user_id=user_id, model_name=model_name, - worker_max_iters=worker_max_iters, enable_thinking=enable_thinking, - chat_mode=chat_mode, emit=emit, is_cancelled=is_cancelled, - project_ctx=project_ctx, chat_id=chat_id, - ontology_enabled=ontology_enabled, - ontology_runtime=ontology_runtime, + in_project=in_project, steering=steering, ) + # Per-iteration 异常隔离:worker 内任何未被流层吞掉的异常(网关断流抛错、 + # AgentScope 内部错、沙箱协议外异常)只废掉**本轮**,绝不冒泡杀死整个多小时 + # run。异常轮与零产出轮同待遇:不计 attempt、退避重试;连续多轮才熔断。 + try: + work = await _run_worker_iteration( + prompt=prompt, session_id=session, user_id=user_id, model_name=model_name, + model_provider_id=model_provider_id, + worker_max_iters=worker_max_iters, enable_thinking=enable_thinking, + chat_mode=chat_mode, emit=emit, is_cancelled=is_cancelled, + project_ctx=project_ctx, chat_id=chat_id, + ontology_enabled=ontology_enabled, + ontology_runtime=ontology_runtime, + ) + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 + logger.warning("[loop %s] iter %d worker raised: %s", loop_id, seq, exc, exc_info=True) + work = {"text": "", "tokens": 0, "tool_calls": 0, "_infra_error": str(exc)[:200]} tokens_spent += work["tokens"] if is_cancelled and is_cancelled(): status, reason = "cancelled", "外部取消" break # Infrastructure-failure guard: a worker round that produced NOTHING - # (no tool calls, no text, no tokens) is an environment outage — e.g. - # an LLM-gateway blip fast-fails every call — not evidence about the - # requirement. Charging it as an attempt/stall let a 10-minute gateway - # outage burn 6 empty rounds and block a requirement that was one - # healthy iteration from passing (observed on the 200-page rerun). - # Back off briefly and retry; the wall-clock/iteration budgets still - # bound a permanent outage. + # (no tool calls, no text, no tokens) — or raised — is an environment + # outage, not evidence about the requirement. Not counted as an + # attempt/stall; back off and retry. A permanent outage is bounded by + # the consecutive-infra circuit breaker (LOOP_MAX_CONSECUTIVE_INFRA). if not work["tokens"] and not work["tool_calls"] and not str(work.get("text") or "").strip(): + consecutive_infra += 1 + if consecutive_infra >= _max_consecutive_infra(): + status = "failed" + reason = ( + f"连续 {consecutive_infra} 轮零产出/异常(疑似模型网关或沙箱持续故障)," + "已熔断。排除环境故障后可「继续」从断点续跑。" + ) + break logger.warning( - "[loop %s] iter %d req=%s produced nothing (infra failure?) — " - "not counted as attempt; backing off 30s", loop_id, seq, req["id"], + "[loop %s] iter %d req=%s produced nothing (infra failure %d/%d) — " + "not counted as attempt; backing off 30s", + loop_id, seq, req["id"], consecutive_infra, _max_consecutive_infra(), ) await _emit(emit, {"type": "iteration_evaluated", "seq": seq, "requirement_id": req["id"], "verdict": "infra_retry", "tool_calls": 0, "tokens": 0, - "reason": "本轮无任何产出(疑似模型/网关故障),不计入尝试,稍后重试"}) + "reason": "本轮无任何产出(疑似模型/网关故障),不计入尝试,稍后重试" + + (f":{work.get('_infra_error')}" if work.get("_infra_error") else "")}) await asyncio.sleep(30) continue + consecutive_infra = 0 req["attempts"] = int(req.get("attempts", 0)) + 1 - # 2) Read-only reviewer sub-agent: independently opens the **real produced files** to verify this requirement (never trusts the worker's self-report). - review = await review_requirement( - objective=goal_spec.objective, requirement_desc=req["description"], - acceptance_criteria=criteria, worker_summary=work["text"], - session_id=session, user_id=user_id, - project_ctx=project_ctx, chat_id=chat_id, - model_name=evaluator_model or model_name, - requirement_id=req["id"], emit=emit, - ) + # 2) 混合验收(Codex「退出码是金标准」+ 语义评审兜底): + # a. 需求带机检命令 → driver **亲自**在沙箱执行(worker 无法作弊)。 + # 机检失败 → 直接反馈命令输出、不烧一次评审 agent(省一整个评审员运行); + # 推进信号退化为「工作区是否真有新改动」。 + # b. 机检通过/无机检 → 只读评审员亲验真实产出(never trust self-report)。 + check_cmd = str(req.get("check_cmd") or "").strip() + check_passed: Optional[bool] = None + machine_evidence = "" + if check_cmd: + _code, _out, _err = await _sbx_exec( + check_cmd, session_id=session, user_id=user_id, timeout=90, + ) + check_passed = _code == 0 + _check_tail = (_out or _err or "").strip()[-500:] + machine_evidence = ( + f"driver 已执行机检命令 `{check_cmd}`,退出码 {_code}" + + (f",输出尾部:{_check_tail}" if _check_tail else "") + ) + await _emit(emit, {"type": "loop_check", "seq": seq, "requirement_id": req["id"], + "cmd": check_cmd, "exit_code": _code, "passed": check_passed}) + + if check_passed is False: + progressed = await _git_worktree_changed(session, user_id) + review = { + "verdict": CONTINUE, + "criteria_hit": [], + "evidence": machine_evidence, + "progress": progressed, + "feedback": f"机检未通过:{machine_evidence}。修复到该命令退出码为 0 再收工。", + } + else: + review = await review_requirement( + objective=goal_spec.objective, requirement_desc=req["description"], + acceptance_criteria=criteria, worker_summary=work["text"], + machine_evidence=machine_evidence, + session_id=session, user_id=user_id, + project_ctx=project_ctx, chat_id=chat_id, + model_name=evaluator_model or model_name, + requirement_id=req["id"], emit=emit, + ) verdict = review.get("verdict") evidence = review.get("evidence", "") @@ -809,23 +1011,34 @@ def _budget_left() -> Optional[str]: logger.info("[loop %s] iter %d req=%s verdict=%s (attempt %d)", loop_id, seq, req["id"], verdict, req["attempts"]) - # 3) Decide the flip (passes: false→true); only the driver may flip — "done" must also pass an independent second-pass re-check. + # 3) Decide the flip (passes: false→true); only the driver may flip. + # 二次复核降频:机检已过(有客观退出码佐证)且不是收官需求 → 一次语义评审即可翻牌; + # 纯语义需求、或翻牌即整环完成的收官需求 → 仍加独立二次复核(防提前收工)。 passed = False if verdict == DONE: - confirm = await review_requirement( - objective=goal_spec.objective, requirement_desc=req["description"], - acceptance_criteria=criteria, worker_summary=work["text"], - session_id=session, user_id=user_id, - project_ctx=project_ctx, chat_id=chat_id, - model_name=evaluator_model or model_name, second_pass=True, - requirement_id=req["id"], emit=emit, + flip_completes = all( + r.get("passes") or r.get("blocked") or r["id"] == req["id"] + for r in ledger["requirements"] ) - if confirm.get("verdict") == DONE: + need_confirm = (check_passed is not True) or flip_completes + if not need_confirm: passed = True - evidence = confirm.get("evidence") or evidence else: - rec["reason"] += "(二次复核未通过,继续)" - logger.info("[loop %s] req %s done 被二次复核驳回", loop_id, req["id"]) + confirm = await review_requirement( + objective=goal_spec.objective, requirement_desc=req["description"], + acceptance_criteria=criteria, worker_summary=work["text"], + machine_evidence=machine_evidence, + session_id=session, user_id=user_id, + project_ctx=project_ctx, chat_id=chat_id, + model_name=evaluator_model or model_name, second_pass=True, + requirement_id=req["id"], emit=emit, + ) + if confirm.get("verdict") == DONE: + passed = True + evidence = confirm.get("evidence") or evidence + else: + rec["reason"] += "(二次复核未通过,继续)" + logger.info("[loop %s] req %s done 被二次复核驳回", loop_id, req["id"]) # 4) HITL: reviewer requests human confirmation (optional per-loop; CE default logs and continues) if not passed and verdict == NEED_HUMAN and hitl_enabled: @@ -874,16 +1087,55 @@ def _budget_left() -> Optional[str]: await _emit(emit, {"type": "loop_awaiting_human", "seq": seq, "reason": reason}) break req["blocked"] = True + req["last_feedback"] = str(review.get("feedback", "") or "")[:400] await _emit(emit, {"type": "loop_stagnation", "seq": seq, "requirement_id": req["id"], "attempts": req["attempts"], "stalls": req["stalls"]}) logger.info("[loop %s] req %s blocked after %d attempts (%d consecutive stalls)", loop_id, req["id"], req["attempts"], req["stalls"]) - feedback = review.get("feedback", "") + # 重规划:需求被搁置说明原拆法/方向可能有问题——对剩余未完成部分重拆 + # (已通过项不动),而不是一条条 blocked 到只剩「部分完成」。次数有护栏。 + if int(ledger.get("replans", 0) or 0) < _max_replans(): + new_reqs = await replan_remaining( + goal_spec=goal_spec, ledger=ledger, + survey=str(ledger.get("survey", "") or ""), + model_name=evaluator_model, user_id=user_id, + ) + if new_reqs: + ledger["replans"] = int(ledger.get("replans", 0) or 0) + 1 + ledger["requirements"] = _init_req_fields(new_reqs) + feedback = "" + handoff = "" + await _persist_ledger(ledger) + await _emit(emit, {"type": "loop_replanned", "seq": seq, + "replans": ledger["replans"]}) + await _emit(emit, {"type": "loop_plan", "objective": goal_spec.objective, + "requirements": [ + {"id": r["id"], "description": r["description"], + "passes": bool(r.get("passes"))} + for r in ledger["requirements"]]}) + logger.info("[loop %s] REPLANNED (#%d): %d requirements", + loop_id, ledger["replans"], len(ledger["requirements"])) + continue + + # 评审反馈只属于**当前需求**:翻牌后清空,绝不把上一条需求的反馈当成下一条的 + # 「评审反馈」注入(历史缺陷:R1 通过后 R2 首轮带着 R1 的结论开工)。 + if passed: + feedback = "" + else: + feedback = review.get("feedback", "") + req["last_feedback"] = feedback[:400] # 6) Handoff + persist ledger/progress (for resume). git diff takes precedence over the text summary. git_diff = await _git_diff_stat(session, user_id) - handoff = await _make_handoff(work["text"], evidence, git_diff) + if passed: + # 交接同理按需求隔离:下一条需求只需要知道「上一条已完成」+ 改动面, + # 不需要上一条的工作细节自述。 + handoff = f"【上一需求 {req['id']} 已完成并通过评审】" + if git_diff: + handoff += f"\n【其改动 git diff --stat】\n{git_diff}" + else: + handoff = await _make_handoff(work["text"], evidence, git_diff) await asyncio.gather( _persist_ledger(ledger), _write_file( @@ -899,6 +1151,30 @@ def _budget_left() -> Optional[str]: ), ) + # 收尾交付轮:部分完成收场(停滞搁置/显式预算耗尽/硬后备触发)时,用现有成果 + # 做一轮「尽力交付」——整合已完成部分、如实列出未竟事项,而不是把半成品散件直接 + # 甩给用户。取消/失败/等待人工不做(前两者用户要么不想要、要么环境坏了)。 + if status == "budget_exhausted" and _wrapup_enabled(): + _done_n = sum(1 for r in ledger["requirements"] if r.get("passes")) + if _done_n and not (is_cancelled and is_cancelled()): + await _emit(emit, {"type": "loop_wrapup_started", + "progress": _progress_frac(ledger)}) + try: + wrap = await _run_worker_iteration( + prompt=_build_wrapup_prompt(goal_spec.objective, ledger, in_project), + session_id=session, user_id=user_id, model_name=model_name, + model_provider_id=model_provider_id, + worker_max_iters=max(6, worker_max_iters // 2), + enable_thinking=enable_thinking, chat_mode=chat_mode, + emit=emit, is_cancelled=is_cancelled, + project_ctx=project_ctx, chat_id=chat_id, + ontology_enabled=ontology_enabled, + ontology_runtime=ontology_runtime, + ) + tokens_spent += wrap["tokens"] + except Exception as exc: # noqa: BLE001 - 收尾失败不改变终态 + logger.warning("[loop %s] wrapup iteration failed: %s", loop_id, exc) + # Convergence/exit: flush the final ledger (sandbox + DB). await _persist_ledger(ledger) if final_score is None: diff --git a/src/backend/orchestration/chat_run_executor.py b/src/backend/orchestration/chat_run_executor.py index 1b90c5a0..7b1501a5 100644 --- a/src/backend/orchestration/chat_run_executor.py +++ b/src/backend/orchestration/chat_run_executor.py @@ -345,7 +345,9 @@ async def _run_workflow( from core.chat.tool_log import ( attach_subagent_step, build_thinking_event, + build_tool_call_delta_event, build_tool_call_event, + build_tool_call_start_event, build_tool_result_event, ) from core.services.artifact_service import persist_artifacts as _persist_artifacts @@ -501,6 +503,13 @@ def _flush_thinking() -> None: _tc.setdefault("content_offset", len(full_response)) await _emit(_tc_evt) + elif chunk_type == "tool_call_start": + _flush_thinking() + await _emit(build_tool_call_start_event(chunk, chat_id)) + + elif chunk_type == "tool_call_delta": + await _emit(build_tool_call_delta_event(chunk, chat_id)) + elif chunk_type == "tool_result": await _emit(build_tool_result_event(chunk, chat_id, tool_calls_log)) @@ -1266,6 +1275,7 @@ async def start_autonomous_loop_run( goal_spec: Dict[str, Any], budget: Dict[str, Any], model_name: Optional[str] = None, + model_provider_id: Optional[str] = None, evaluator_model: Optional[str] = None, worker_max_iters: int = 15, hitl_enabled: bool = False, @@ -1284,6 +1294,7 @@ async def start_autonomous_loop_run( "goal_spec": goal_spec, "budget": budget, "model_name": model_name, + "model_provider_id": model_provider_id, "evaluator_model": evaluator_model, "worker_max_iters": worker_max_iters, "hitl_enabled": hitl_enabled, @@ -1295,6 +1306,24 @@ async def start_autonomous_loop_run( "is_resume": is_resume, "project_id": project_id, } + # 启动参数跟 loop 持久化(而非只活在本次请求里):崩溃/重启后的续跑——无论 API + # resume 还是启动自动续跑——都能还原同一套模型/评审模型/轮数/思考档位,不再悄悄 + # 降级到默认值。 + try: + from core.services.loop_service import LoopService as _LoopSvc + + with SessionLocal() as db: + _LoopSvc(db).save_start_params(loop_id, { + "model_name": model_name, + "model_provider_id": model_provider_id, + "evaluator_model": evaluator_model, + "worker_max_iters": worker_max_iters, + "hitl_enabled": hitl_enabled, + "enable_thinking": enable_thinking, + "chat_mode": chat_mode, + }) + except Exception: # noqa: BLE001 - 参数存档失败不阻塞启动 + logger.warning("loop start_params persist failed", exc_info=True) run = _create_run_record(chat_id=chat_id, user_id=user_id, request_payload=request_payload) _register_run_task( run.run_id, @@ -1307,6 +1336,7 @@ async def start_autonomous_loop_run( goal_spec=goal_spec, budget=budget, model_name=model_name, + model_provider_id=model_provider_id, evaluator_model=evaluator_model, worker_max_iters=worker_max_iters, hitl_enabled=hitl_enabled, @@ -1371,8 +1401,9 @@ async def _run_autonomous_loop_workflow( goal_spec: Dict[str, Any], budget: Dict[str, Any], model_name: Optional[str], - evaluator_model: Optional[str], - worker_max_iters: int, + model_provider_id: Optional[str] = None, + evaluator_model: Optional[str] = None, + worker_max_iters: int = 15, hitl_enabled: bool = False, enable_thinking: bool = False, chat_mode: Optional[str] = None, @@ -1456,10 +1487,13 @@ def _flush_loop_message(status: str = "running") -> None: objective=goal_spec.get("objective", ""), acceptance_criteria=goal_spec.get("acceptance_criteria", []) or [], ) + # 预算去一等公民化:缺省一律 0(不限)。「能完成任务」优先——停止条件回归 + # 账本全通过/停滞无解/用户取消;防失控由 LOOP_HARD_MAX_ITERS 硬后备兜底。 + # 显式传正数的旧 loop 行(历史数据)仍按其预算执行。 bud = LoopBudget( - max_iters=int(budget.get("max_iters", 50)), - max_wall_clock_s=float(budget.get("max_wall_clock_s", 6 * 3600)), - max_tokens=int(budget.get("max_tokens", 10_000_000)), + max_iters=int(budget.get("max_iters", 0) or 0), + max_wall_clock_s=float(budget.get("max_wall_clock_s", 0) or 0), + max_tokens=int(budget.get("max_tokens", 0) or 0), ) # Project binding: resolve project_ctx and bind the loop's chat session to # that project — this scopes the worker's/reviewer's file tools to the @@ -1515,6 +1549,15 @@ def _save_ledger(led: Dict[str, Any]) -> None: with SessionLocal() as db: _LoopSvc(db).save_ledger(loop_id, led) + def _poll_steering() -> List[str]: + """每轮开工前取走用户运行中追加的指令(POST /v1/loops/{id}/steer)。""" + try: + with SessionLocal() as db: + return _LoopSvc(db).consume_steering(loop_id) + except Exception: # noqa: BLE001 + logger.warning("loop consume_steering failed", exc_info=True) + return [] + try: await _emit( { @@ -1554,7 +1597,10 @@ def _save_ledger(led: Dict[str, Any]) -> None: goal_spec=gs, budget=bud, model_name=model_name, - evaluator_model=evaluator_model or "fast", + model_provider_id=model_provider_id, + # 评审/规划模型:显式指定 > 「模型管理 → 角色分配 → loop_reviewer」> + # main_agent。不再硬编码 "fast"——评审质量值得一个后台可配的位置。 + evaluator_model=evaluator_model, worker_max_iters=worker_max_iters, session_id=session_id, hitl_enabled=hitl_enabled, @@ -1564,6 +1610,7 @@ def _save_ledger(led: Dict[str, Any]) -> None: is_cancelled=lambda: is_run_cancelled(run_id), load_ledger=_load_ledger, save_ledger=_save_ledger, + poll_steering=_poll_steering, project_ctx=project_ctx, chat_id=chat_id, # Carried explicitly so the loop resolves *this* tenant's @@ -1950,19 +1997,22 @@ async def recover_orphan_runs() -> int: async def resume_running_loops() -> int: - """At startup, resume autonomous loops interrupted by a crash/restart (M4 checkpoint resume). - - The persistent sandbox files (feature_list.json/handoffs.md) still exist - (same session) → re-invoke start_autonomous_loop_run with the same loop_id, - and the driver automatically resumes from feature_list.json. Only orphan - loops with status='running' are resumed; 'awaiting_human'/terminal states - are left alone. Off by default (LOOP_AUTO_RESUME=false) to avoid accidental - re-runs in shared environments. + """启动对账 + 按需自动续跑:进程重启后收养/归位孤儿自主循环。 + + 进程刚起来时不存在任何活跃 task,因此 status='running' 的 loop 全是孤儿。 + 对每一个孤儿: + + - ``LOOP_AUTO_RESUME=true`` → 用**持久化的启动参数**(agent_loops.extra_data. + start_params:模型/评审模型/轮数/思考档位)原样续跑——账本在 DB 有镜像、 + 沙箱有 feature_list.json,driver 自动断点续跑,不再悄悄降级到默认模型。 + - 关闭(默认)→ 状态归位为 ``interrupted``(可续跑),不再留下永远 running 的 + 僵尸行(历史坑:僵尸 running 行既误导列表页、又让 cancel 无处下手)。 + + 'awaiting_human'/终态一律不动。 """ - if os.getenv("LOOP_AUTO_RESUME", "false").strip().lower() not in ("1", "true", "yes"): - return 0 from core.db.models import AgentLoop + auto = os.getenv("LOOP_AUTO_RESUME", "false").strip().lower() in ("1", "true", "yes") resumed = 0 with SessionLocal() as db: loops = db.query(AgentLoop).filter(AgentLoop.status == "running").all() @@ -1974,10 +2024,24 @@ async def resume_running_loops() -> int: dict(x.goal_spec or {}), dict(x.budget or {}), (x.extra_data or {}).get("project_id"), + dict((x.extra_data or {}).get("start_params") or {}), ) for x in loops ] - for loop_id, chat_id, user_id, goal_spec, budget, project_id in specs: + + if not auto: + if specs: + from core.services.loop_service import LoopService as _LoopSvc + + with SessionLocal() as db: + for loop_id, *_rest in specs: + _LoopSvc(db).mark_interrupted( + loop_id, reason="服务重启导致运行中断,可点击「继续」从断点续跑" + ) + logger.info("[startup] orphan loops marked interrupted: %d", len(specs)) + return 0 + + for loop_id, chat_id, user_id, goal_spec, budget, project_id, params in specs: if not chat_id: continue try: @@ -1987,6 +2051,13 @@ async def resume_running_loops() -> int: user_id=user_id, goal_spec=goal_spec, budget=budget, + model_name=params.get("model_name"), + model_provider_id=params.get("model_provider_id"), + evaluator_model=params.get("evaluator_model"), + worker_max_iters=int(params.get("worker_max_iters") or 15), + hitl_enabled=bool(params.get("hitl_enabled")), + enable_thinking=bool(params.get("enable_thinking")), + chat_mode=params.get("chat_mode"), project_id=project_id, is_resume=True, ) @@ -2077,6 +2148,19 @@ async def reap_stale_runs() -> int: if began_at is not None and began_at.tzinfo is None: # SQLite stores naive UTC began_at = began_at.replace(tzinfo=timezone.utc) hard_expired = began_at is not None and began_at < hard_cutoff + payload = row.request_payload if isinstance(row.request_payload, dict) else {} + + # 自主循环豁免统一年龄硬顶:loop 的使命就是「跑到任务完成为止」(去预算化), + # 且它有自己的停滞护栏/熔断/硬后备轮数。进程内 task 还活着的 loop 绝不按 + # 年龄杀(历史竞态:6h 硬顶 == loop 旧默认预算,跑满预算的健康 loop 在优雅 + # 收尾前被硬顶抢杀,报错还误导为「无响应」)。孤儿 loop(进程重启遗留) + # 走下面的静默判据正常清理。 + if hard_expired and payload.get("kind") == "autonomous_loop": + task = _active_runs.get(rid) + if task is not None and not task.done(): + logger.info("chat_run_stale_skip_loop_inprocess", run_id=rid) + continue + hard_expired = False # 孤儿 loop:不按年龄硬杀,交给静默判据 if not hard_expired: # A run whose asyncio task is alive in THIS process is never a diff --git a/src/backend/orchestration/loop_evaluator.py b/src/backend/orchestration/loop_evaluator.py index 886eb843..36a4cb10 100644 --- a/src/backend/orchestration/loop_evaluator.py +++ b/src/backend/orchestration/loop_evaluator.py @@ -174,6 +174,9 @@ async def _make_judge_agent(model_name: Optional[str], user_id: str): enabled_skill_ids=[], # Required; otherwise the all-skills fallback lets the pure-text agent run tools chat_mode="fast", model_name=model_name, + # 与评审员/规划器同一个后台可配角色(模型管理 → 自主循环评审与规划); + # 未配置时回落 main_agent。 + model_role="loop_reviewer", current_user_id=user_id, ) return agent, clients diff --git a/src/backend/orchestration/loop_planner.py b/src/backend/orchestration/loop_planner.py new file mode 100644 index 00000000..1673bcfe --- /dev/null +++ b/src/backend/orchestration/loop_planner.py @@ -0,0 +1,294 @@ +"""自主循环规划器 v2 —— 侦察式规划 + 运行中重规划。 + +旧规划链路(loop_evaluator.decompose_requirements)的问题:用 fast 模型、disable_tools、 +看不到任何项目文件,凭目标文本一次盲拆定终身;拆错方向整个 run 陪葬(Codex goal 模式是 +主模型带工具在 repo 里调查后再规划)。本模块把规划拆成三步,全部以「循环评审与规划」 +模型角色(模型管理 → 角色分配 → loop_reviewer,未配置回落 main_agent)驱动: + + 1. :func:`scout_workspace` —— 只读侦察 agent 绑到与 worker 相同的沙箱/项目,亲自 + ls/read/grep 摸清现状(已有文件、已有进展、缺口),产出一段结构化侦察纪要。 + 纯任务型循环(无项目绑定)且 /workspace 为空时快速跳过,不烧一次 agent。 + 2. :func:`plan_requirements` —— 侦察纪要 + 目标 → 需求账本。每条需求可附一条**只读 + check_cmd**(driver 亲自在沙箱执行,退出码 0 即机检达标——worker 无法作弊), + 恢复 Codex「exit code 是金标准」的混合验收;语义类需求拿不准就不附。 + 简单目标允许只拆 1~2 条,不再强制 3~8 的拆解仪式。 + 3. :func:`replan_remaining` —— 运行中有需求被 blocked 时,对「未通过的剩余部分」 + 重拆(已通过的需求原样保留、不可撤销)。重拆次数由 driver 用护栏封顶,防止 + 规划抖动本身变成死循环。 + +所有函数失败时返回兜底值(None/[]),绝不拖垮循环——driver 侧永远有旧链路可退。 +""" +from __future__ import annotations + +import json +from typing import Any, Dict, List, Optional + +from core.infra.logging import get_logger +from orchestration.loop_evaluator import GoalSpec, _parse_json_array_lenient + +logger = get_logger(__name__) + +# 侦察纪要长度上限(进入规划 prompt 与账本存档的截断值) +_SURVEY_CAP = 3000 +_MODEL_ROLE = "loop_reviewer" + + +# ── 侦察:只读 agent 亲自摸一遍工作区/项目 ───────────────────────────────────── +async def _workspace_is_empty(session_id: str, user_id: str) -> bool: + """纯任务型循环的快速短路:/workspace 还什么都没有就不值得起一次侦察 agent。""" + from core.sandbox import ExecuteRequest, get_sandbox_provider + + try: + res = await get_sandbox_provider().execute( + ExecuteRequest( + script_content="ls -A /workspace 2>/dev/null | grep -v '^\\.' | head -5", + script_name="_loop_scout_ls.sh", + language="bash", + timeout=30, + session_id=session_id, + user_id=user_id, + ) + ) + return not (res.stdout or "").strip() + except Exception as exc: # noqa: BLE001 - 探测失败按「非空」处理,走完整侦察 + logger.info("[loop-plan] workspace probe failed (%s), assuming non-empty", exc) + return False + + +async def scout_workspace( + *, + objective: str, + session_id: str, + user_id: str, + project_ctx: Optional[Dict[str, Any]] = None, + chat_id: Optional[str] = None, + model_name: Optional[str] = None, +) -> str: + """只读侦察:绑定 worker 同款沙箱/项目,摸清现状后输出结构化纪要(失败返回 "")。""" + if not project_ctx and await _workspace_is_empty(session_id, user_id): + logger.info("[loop-plan] empty workspace, skip scouting") + return "" + + from core.llm.agent_factory import create_agent_executor + from core.llm.mcp_manager import close_clients + from orchestration.streaming import StreamingAgent + + where = "用户选定的项目文件夹(站点/工程真实源码所在)" if project_ctx else "沙箱 /workspace" + prompt = ( + "你是一个自主循环的**开工侦察员**(只读)。循环马上要围绕下面的目标开工," + f"你先亲自把{where}摸一遍,给规划器写一份侦察纪要。\n\n" + f"## 目标\n{objective}\n\n" + "## 侦察要求\n" + "1. `ls` / glob 摸清有哪些文件与目录结构;挑与目标最相关的 3~6 个文件读关键部分" + "(大文件用 head/grep/wc 抽样,禁止整读)。\n" + "2. 判断:哪些目标要件**已经存在/已有雏形**,哪些**完全缺失**,有什么坑(依赖、" + "格式、构建方式)。\n" + "3. 只读取证,禁止创建/修改/删除任何文件。\n\n" + "## 输出(≤600字,markdown)\n" + "### 现状\n- 目录结构与关键文件一句话点评\n" + "### 已有进展\n- 与目标相关的既有成果\n" + "### 缺口\n- 距离目标还缺什么\n" + "### 风险与建议\n- 构建/格式/依赖上的注意点,建议的切入顺序" + ) + try: + agent, clients = await create_agent_executor( + current_user_id=user_id, + model_name=model_name, + model_role=_MODEL_ROLE, + sandbox_session_id=session_id, + project_ctx=project_ctx, + chat_id=chat_id, + enabled_skill_ids=[], + isolated=True, + read_only=True, + allow_bash=True, + max_iters=10, + tool_result_limit=6000, + ) + except Exception as exc: # noqa: BLE001 + logger.warning("[loop-plan] scout spawn failed: %s", exc) + return "" + + sa = StreamingAgent(agent, clients) + text = "" + try: + async for et, payload in sa.stream( + [{"role": "user", "content": prompt}], + {"user_id": user_id, "model_name": model_name or "", + "enable_thinking": False, "chat_mode": "medium"}, + ): + if et == "text_delta": + text += payload + elif et == "error": + logger.warning("[loop-plan] scout stream error: %s", payload) + except Exception as exc: # noqa: BLE001 + logger.warning("[loop-plan] scout run failed: %s", exc) + finally: + await close_clients(clients) + return text.strip()[:_SURVEY_CAP] + + +# ── 规划:侦察纪要 + 目标 → 需求账本(含可选 check_cmd) ──────────────────────── +_CHECK_CMD_RULES = ( + "check_cmd 规则:**只读、幂等、90 秒内出结果**的 bash 命令,在工作区根目录执行," + "退出码 0 即该需求客观达标(如 `test -f xxx`、`grep -q '关键实现' 文件`、" + "`[ $(wc -m < draft.md) -ge 50000 ]`、`grep -c '^# 第' draft.md | grep -qx 20`)。" + "只有能用命令**客观判定**的需求才附;语义/质量类需求(写得好不好、逻辑是否通顺)" + "一律省略该字段,交给评审员。禁止写任何会修改文件的命令。" +) + + +async def _plan_llm_once(prompt: str, *, model_name: Optional[str], user_id: str) -> str: + """规划专用的一次性纯文本调用:loop_reviewer 角色 + medium 档(规划值得比 fast 更强的模型)。""" + from core.llm.agent_factory import create_agent_executor + from core.llm.mcp_manager import close_clients + from orchestration.streaming import StreamingAgent + + agent, clients = await create_agent_executor( + disable_tools=True, + enabled_skill_ids=[], + chat_mode="medium", + model_name=model_name, + model_role=_MODEL_ROLE, + current_user_id=user_id, + ) + sa = StreamingAgent(agent, clients) + text = "" + try: + async for et, payload in sa.stream( + [{"role": "user", "content": prompt}], + {"user_id": user_id, "enable_thinking": False, "chat_mode": "medium"}, + ): + if et == "text_delta": + text += payload + elif et == "error": + logger.warning("[loop-plan] plan LLM error: %s", payload) + break + finally: + await close_clients(clients) + return text + + +def _sanitize_requirements(items: Optional[List[Any]], *, id_prefix: str = "R") -> List[Dict[str, Any]]: + reqs: List[Dict[str, Any]] = [] + for i, raw in enumerate(items or [], start=1): + if not isinstance(raw, dict): + continue + desc = str(raw.get("description", "")).strip() + if not desc: + continue + entry: Dict[str, Any] = {"id": str(raw.get("id") or f"{id_prefix}{i}"), "description": desc} + cmd = str(raw.get("check_cmd", "") or "").strip() + if cmd: + entry["check_cmd"] = cmd + reqs.append(entry) + return reqs + + +async def plan_requirements( + *, + goal_spec: GoalSpec, + survey: str, + model_name: Optional[str], + user_id: str, +) -> List[Dict[str, Any]]: + """侦察纪要 + 目标 → 需求账本。失败返回 [](driver 退回旧 decompose 链路)。""" + criteria_block = ( + "已知验收标准(据此拆,勿遗漏):\n" + + "\n".join(f"- {c}" for c in goal_spec.acceptance_criteria) + "\n\n" + if goal_spec.acceptance_criteria else "" + ) + survey_block = ( + f"## 侦察纪要(侦察员亲自查看工作区后的实况,规划必须以此为准)\n{survey}\n\n" + if survey else "" + ) + prompt = ( + "你是一个自主循环的规划器。把目标拆成一组**离散、可独立核验**的需求账本," + "循环会一次只啃一条、逐条做扎实、逐条由独立评审员核验。\n\n" + f"## 目标\n{goal_spec.objective}\n\n" + + criteria_block + + survey_block + + "## 拆解规则\n" + "1. 条数**按任务体量定**:简单目标 1~2 条即可,复杂目标最多 8 条;不要为了拆而拆。\n" + "2. 每条是一个能客观判断「做没做到」的具体特性/改动/交付物;粒度适中。\n" + "3. 若侦察纪要显示某要件**已存在且达标**,不要再立需求重做;在既有成果上补缺口。\n" + "4. 按依赖与优先级排序(先地基后装修)。\n" + f"5. {_CHECK_CMD_RULES}\n\n" + "## 输出\n严格只输出 JSON 数组,每个元素形如 " + '{"id":"R1","description":"...","check_cmd":"..."}(check_cmd 可省略)。' + "不要任何多余文字。" + ) + try: + text = await _plan_llm_once(prompt, model_name=model_name, user_id=user_id) + reqs = _sanitize_requirements(_parse_json_array_lenient(text)) + if reqs: + return reqs[:8] + except Exception as exc: # noqa: BLE001 - 规划失败绝不拖垮循环 + logger.warning("[loop-plan] plan_requirements failed: %s", exc) + return [] + + +# ── 重规划:blocked 后对剩余部分重拆(passed 不动) ───────────────────────────── +async def replan_remaining( + *, + goal_spec: GoalSpec, + ledger: Dict[str, Any], + survey: str, + model_name: Optional[str], + user_id: str, +) -> Optional[List[Dict[str, Any]]]: + """对未通过的剩余需求重拆。返回**完整的新账本需求列表**(已通过项原样保留在前), + 失败/无法改进时返回 None(driver 维持原账本)。""" + passed = [r for r in ledger.get("requirements", []) if r.get("passes")] + remaining = [r for r in ledger.get("requirements", []) if not r.get("passes")] + if not remaining: + return None + + def _fmt(r: Dict[str, Any]) -> str: + flags = [] + if r.get("blocked"): + flags.append(f"已尝试 {r.get('attempts', 0)} 轮未过被搁置") + note = f"({';'.join(flags)})" if flags else "" + fb = str(r.get("last_feedback", "") or "")[:200] + fb_line = f"\n 最近评审反馈:{fb}" if fb else "" + return f"- {r['id']}: {r['description']}{note}{fb_line}" + + prompt = ( + "你是一个自主循环的规划器。循环执行中有需求多轮未通过评审被搁置," + "说明**原来的拆法或方向可能有问题**。请只对「剩余未完成部分」重新规划。\n\n" + f"## 总目标\n{goal_spec.objective}\n\n" + + (f"## 侦察纪要(工作区实况)\n{survey}\n\n" if survey else "") + + "## 已通过的需求(**不许动**,重规划不得与其重复)\n" + + ("\n".join(f"- {r['id']}: {r['description']}" for r in passed) or "(无)") + + "\n\n## 未完成的需求(重规划对象,含搁置原因)\n" + + "\n".join(_fmt(r) for r in remaining) + + "\n\n## 重规划规则\n" + "1. 剖析搁置原因:是需求太大(拆细)、方向错了(换方案)、还是环境根本做不到(改成可达成的等价目标)。\n" + "2. 输出**替换全部未完成需求**的新需求列表(1~6 条),总目标不变、只换实现路径。\n" + "3. 若你判断原拆法没问题、纯粹是执行没到位,输出原需求(可微调描述给出更具体的做法提示)。\n" + f"4. {_CHECK_CMD_RULES}\n\n" + "## 输出\n严格只输出 JSON 数组(新的未完成需求列表),元素形如 " + '{"id":"N1","description":"...","check_cmd":"..."}。不要任何多余文字。' + ) + try: + text = await _plan_llm_once(prompt, model_name=model_name, user_id=user_id) + fresh = _sanitize_requirements(_parse_json_array_lenient(text), id_prefix="N") + if not fresh: + return None + # 已通过项原样保留在前,新需求接续其后;防 id 撞车 + used = {r["id"] for r in passed} + for r in fresh: + if r["id"] in used: + r["id"] = f"N{len(used) + 1}" + used.add(r["id"]) + return passed + fresh + except Exception as exc: # noqa: BLE001 + logger.warning("[loop-plan] replan failed: %s", exc) + return None + + +def summarize_plan_for_log(reqs: List[Dict[str, Any]]) -> str: + return json.dumps( + [{"id": r["id"], "check": bool(r.get("check_cmd"))} for r in reqs], + ensure_ascii=False, + ) diff --git a/src/backend/orchestration/streaming.py b/src/backend/orchestration/streaming.py index b134461a..d5c4c77a 100644 --- a/src/backend/orchestration/streaming.py +++ b/src/backend/orchestration/streaming.py @@ -1,17 +1,18 @@ """Streaming agent wrapper for AgentScope 2.0. Consumes ``agent.reply_stream(...)`` (replaces the 1.x msg_queue) and maps the 25 -fine-grained EventType into our 8 SSE events: +fine-grained EventType into the internal events consumed by ``workflow.py``: - ("text_delta", str) - incremental answer text - ("thinking_delta", str) - incremental reasoning -- ("tool_pending", dict) - tool call started (args still streaming) +- ("tool_call_start", dict)- tool call started (args still streaming) +- ("tool_call_delta", dict)- incremental tool-call argument JSON - ("tool_call", dict) - tool invocation complete - ("tool_result", dict) - tool invocation result - ("file_confirm", dict) - myspace write confirmation (in-house ContextVar gate, distinct from native HITL) - ("heartbeat", None) - silence heartbeat (queue empty ≥3s: the model produced nothing at all) - ("model_progress", None) - throttled liveness signal, emitted in two situations. (a) upstream events - keep arriving but none maps to an SSE event (tool-call args / suppressed - thinking streaming); (b) a model call is in flight (ModelCallStart seen, + keep arriving but none maps to an SSE event (throttled tool-call args / + suppressed thinking streaming); (b) a model call is in flight (ModelCallStart seen, no end yet) and the queue is silent — a hung/slow LLM endpoint produces zero events per attempt, and without this signal the run inactivity watchdog killed healthy-but-waiting runs as "卡死" instead of letting the @@ -70,6 +71,14 @@ # testability. _QUEUE_POLL_INTERVAL_S = 3.0 +# Tool-call argument deltas can arrive one or two characters at a time. Sending +# every upstream fragment recreates the historical SSE storm (hundreds of +# events for a single heredoc), while swallowing all fragments hides a model +# capability the UI can render. Flush when either threshold is crossed to +# preserve visible streaming without forwarding every tiny fragment. +_TOOL_CALL_DELTA_FLUSH_INTERVAL_S = 0.05 +_TOOL_CALL_DELTA_FLUSH_CHARS = 256 + def _looks_like_tool_error(content: str) -> bool: if not content: @@ -128,6 +137,9 @@ def __init__( # tool_id → name (recorded at ToolCallStart), tool_id → accumulated args string (ToolCallDelta) self._tool_name_buf: Dict[str, str] = {} self._tool_args_buf: Dict[str, str] = {} + # tool_id → not-yet-emitted argument delta / last flush timestamp + self._tool_delta_emit_buf: Dict[str, str] = {} + self._tool_delta_last_emit: Dict[str, float] = {} # tool_id → accumulated result text (ToolResultTextDelta) self._tool_result_buf: Dict[str, str] = {} # Accumulated answer text (for dedup in the -suppression scenario) @@ -360,6 +372,8 @@ async def _produce(): except Exception: logger.debug("pending tool_call flush failed", exc_info=True) self._pending_tool_calls.clear() + self._tool_delta_emit_buf.clear() + self._tool_delta_last_emit.clear() try: _log_ctx.__exit__(None, None, None) except Exception: @@ -421,24 +435,54 @@ async def _map_event(self, ev: Any) -> AsyncIterator[Tuple[str, Any]]: name = getattr(ev, "tool_call_name", "") or "unknown" self._tool_name_buf[tid] = name self._tool_args_buf[tid] = "" + self._tool_delta_emit_buf[tid] = "" + self._tool_delta_last_emit[tid] = time.monotonic() self._pending_tool_calls[tid] = { "tool_name": name, "tool_args": None, "started_monotonic": time.monotonic(), "started_at": datetime.now(timezone.utc), } - yield ("tool_pending", {"reason": "tool_call_start", "tool_name": name}) + yield ("tool_call_start", {"name": name, "id": tid}) return if nm == "ToolCallDeltaEvent": tid = getattr(ev, "tool_call_id", "") or "" - self._tool_args_buf[tid] = self._tool_args_buf.get(tid, "") + (getattr(ev, "delta", "") or "") + delta = getattr(ev, "delta", "") or "" + if not delta: + return + self._tool_args_buf[tid] = self._tool_args_buf.get(tid, "") + delta + pending_delta = self._tool_delta_emit_buf.get(tid, "") + delta + self._tool_delta_emit_buf[tid] = pending_delta + now = time.monotonic() + last_emit = self._tool_delta_last_emit.get(tid, now) + if ( + len(pending_delta) >= _TOOL_CALL_DELTA_FLUSH_CHARS + or now - last_emit >= _TOOL_CALL_DELTA_FLUSH_INTERVAL_S + ): + self._tool_delta_emit_buf[tid] = "" + self._tool_delta_last_emit[tid] = now + yield ( + "tool_call_delta", + { + "name": self._tool_name_buf.get(tid, "unknown"), + "id": tid, + "delta": pending_delta, + }, + ) return if nm == "ToolCallEndEvent": tid = getattr(ev, "tool_call_id", "") or "" name = self._tool_name_buf.pop(tid, "unknown") args_str = self._tool_args_buf.pop(tid, "") + pending_delta = self._tool_delta_emit_buf.pop(tid, "") + self._tool_delta_last_emit.pop(tid, None) + if pending_delta: + yield ( + "tool_call_delta", + {"name": name, "id": tid, "delta": pending_delta}, + ) import json try: args = json.loads(args_str) if args_str else {} diff --git a/src/backend/orchestration/subagents/loop_reviewer.py b/src/backend/orchestration/subagents/loop_reviewer.py index 5f4e277d..ef84b4ad 100644 --- a/src/backend/orchestration/subagents/loop_reviewer.py +++ b/src/backend/orchestration/subagents/loop_reviewer.py @@ -64,6 +64,7 @@ def _build_review_prompt( acceptance_criteria: List[str], worker_summary: str, second_pass: bool, + machine_evidence: str = "", ) -> str: criteria = "\n".join(f"- {c}" for c in acceptance_criteria) or "- (无显式验收标准,按需求描述核验)" parts = [ @@ -82,6 +83,12 @@ def _build_review_prompt( "continue 的理由;只有当本轮需求本身就是最终交付(或明确引用了某条全局标准)时," "才逐条核对对应标准。", ] + if machine_evidence.strip(): + parts.append( + "\n## 机检结果(driver 亲自执行的只读命令,**可信的客观证据**)\n" + f"{machine_evidence.strip()[:600]}\n" + "机检只证明命令层面的达标(存在/数量/构建),内容质量与语义仍需你亲自核验。" + ) if worker_summary.strip(): parts.append( "\n## 执行 agent 的自述(**仅作线索,不是证据**)\n" @@ -136,6 +143,7 @@ async def review_requirement( worker_summary: str, session_id: str, user_id: str, + machine_evidence: str = "", project_ctx: Optional[Dict[str, Any]] = None, chat_id: Optional[str] = None, model_name: Optional[str] = None, @@ -198,6 +206,7 @@ async def _finish(status: str, *, output: str = "", error: Optional[str] = None) acceptance_criteria=acceptance_criteria, worker_summary=worker_summary, second_pass=second_pass, + machine_evidence=machine_evidence, ) try: # Reuse the platform-registered builtin reviewer (builtin.reviewer) — @@ -217,6 +226,9 @@ async def _finish(status: str, *, output: str = "", error: Optional[str] = None) user_agent=build_builtin_runtime_profile(_spec, None), current_user_id=user_id, model_name=model_name, + # 评审模型可在「模型管理 → 角色分配 → 自主循环评审与规划」独立配置; + # 显式 model_name(evaluator_model)优先,角色未配置回落 main_agent。 + model_role="loop_reviewer", sandbox_session_id=session_id, # key: same sandbox as the worker → reads real output project_ctx=project_ctx, # key: scope to the project folder (where site source lives) chat_id=chat_id, diff --git a/src/backend/orchestration/workflow.py b/src/backend/orchestration/workflow.py index 8d420e2c..72515b33 100644 --- a/src/backend/orchestration/workflow.py +++ b/src/backend/orchestration/workflow.py @@ -123,7 +123,7 @@ def _synthesize_missing_tool_call( ``_tool_args_ready`` holds back the tool card while streamed args are still incomplete, but it cannot tell "not written yet" from "there is nothing to - write": a zero-argument tool (``get_latest_ai_news`` and friends) keeps + write": a zero-argument tool (for example, a parameterless status probe) keeps empty args forever, so its card is suppressed for the whole run. Without a card the frontend has nothing to attach the result to and — with tools running in parallel — binds it onto whichever sibling is still running, @@ -601,6 +601,30 @@ def _is_revision_opening_boundary(line_before_open: str) -> bool: await _publish( {"type": "ontology_revision_thinking", "delta": str(event_payload or "")} ) + elif event_type == "tool_call_start": + tool_name = str(event_payload.get("name") or "unknown") + if tool_name != "update_plan": + await _publish( + { + "type": "tool_call_start", + "tool_name": tool_name, + "tool_display_name": TOOL_DISPLAY_NAMES.get(tool_name, tool_name), + "tool_id": str(event_payload.get("id") or ""), + "scope": "ontology_revision", + } + ) + elif event_type == "tool_call_delta": + tool_name = str(event_payload.get("name") or "unknown") + if tool_name != "update_plan" and event_payload.get("delta"): + await _publish( + { + "type": "tool_call_delta", + "tool_name": tool_name, + "tool_id": str(event_payload.get("id") or ""), + "arguments_delta": str(event_payload.get("delta") or ""), + "scope": "ontology_revision", + } + ) elif event_type == "tool_call": tool_name = str(event_payload.get("name") or "unknown") tool_id = str(event_payload.get("id") or "") @@ -660,7 +684,15 @@ def _is_revision_opening_boundary(line_before_open: str) -> bool: allocator, ) sub_type = str((event_payload or {}).get("sub_type") or "") - if sub_type in {"start", "thinking", "content", "tool_call", "tool_result", "end"}: + if sub_type in { + "start", + "thinking", + "content", + "tool_call", + "tool_call_delta", + "tool_result", + "end", + }: await _publish( { "type": "subagent_event", @@ -1633,6 +1665,26 @@ async def _finish_direct_log( elif event_type == "thinking_delta": yield {"type": "thinking", "delta": payload} + elif event_type == "tool_call_start": + tool_name = payload.get("name", "unknown") + if tool_name != "update_plan": + yield { + "type": "tool_call_start", + "tool_name": tool_name, + "tool_display_name": TOOL_DISPLAY_NAMES.get(tool_name, tool_name), + "tool_id": payload.get("id", ""), + } + + elif event_type == "tool_call_delta": + tool_name = payload.get("name", "unknown") + if tool_name != "update_plan" and payload.get("delta"): + yield { + "type": "tool_call_delta", + "tool_name": tool_name, + "tool_id": payload.get("id", ""), + "arguments_delta": payload.get("delta", ""), + } + elif event_type == "tool_call": tool_name = payload.get("name", "unknown") tool_id = payload.get("id", "") @@ -1779,9 +1831,10 @@ async def _finish_direct_log( elif event_type in ("heartbeat", "model_progress"): # heartbeat = transport keep-alive; model_progress = the - # model is still streaming (tool-call args etc.) though - # nothing maps to an SSE event — forwarded so the run - # watchdog counts activity (it excludes only heartbeat). + # model is still streaming while a small argument batch or + # another suppressed event has nothing renderable yet — + # forwarded so the run watchdog counts activity (it + # excludes only heartbeat). yield {"type": event_type} elif event_type == "tool_pending": @@ -2554,6 +2607,26 @@ async def astream_chat_workflow( elif event_type == "thinking_delta": yield {"type": "thinking", "delta": payload} + elif event_type == "tool_call_start": + tool_name = payload.get("name", "unknown") + if tool_name != "update_plan": + yield { + "type": "tool_call_start", + "tool_name": tool_name, + "tool_display_name": TOOL_DISPLAY_NAMES.get(tool_name, tool_name), + "tool_id": payload.get("id", ""), + } + + elif event_type == "tool_call_delta": + tool_name = payload.get("name", "unknown") + if tool_name != "update_plan" and payload.get("delta"): + yield { + "type": "tool_call_delta", + "tool_name": tool_name, + "tool_id": payload.get("id", ""), + "arguments_delta": payload.get("delta", ""), + } + elif event_type == "tool_call": tool_name = payload.get("name", "unknown") tool_id = payload.get("id", "") @@ -2825,9 +2898,10 @@ async def astream_chat_workflow( elif event_type in ("heartbeat", "model_progress"): # heartbeat = transport keep-alive; model_progress = the - # model is still streaming (tool-call args etc.) though - # nothing maps to an SSE event — forwarded so the run - # watchdog counts activity (it excludes only heartbeat). + # model is still streaming while a small argument batch or + # another suppressed event has nothing renderable yet — + # forwarded so the run watchdog counts activity (it + # excludes only heartbeat). yield {"type": event_type} elif event_type == "tool_pending": diff --git a/src/backend/prompts/prompt_text/code_exec/system/00_sandbox_environment.system.md b/src/backend/prompts/prompt_text/code_exec/system/00_sandbox_environment.system.md index 5a209c15..2f61a756 100644 --- a/src/backend/prompts/prompt_text/code_exec/system/00_sandbox_environment.system.md +++ b/src/backend/prompts/prompt_text/code_exec/system/00_sandbox_environment.system.md @@ -1,7 +1,6 @@ ## 代码沙箱环境 - 隔离的云端沙箱(**不是**用户本地):Debian 12 / Python 3.11 / Node.js / bash,起始目录 `/workspace/`。 -- **无网络**:不能联网、连数据库或调外部 API。爬虫/在线请求类任务如实告知并给替代方案。 -- 资源:内存 256MB、CPU 1 核、单文件 ≤50MB、命令超时默认 60s / 最大 120s。数据大就分块或采样。 -- 预装免装:pandas、numpy、matplotlib、seaborn、scipy、openpyxl、xlsxwriter;缺库时不要尝试联网安装,改用预装库、标准库或纯本地实现。 -- 不可用:网络请求、数据库、GPU(torch/CUDA)、交互输入 `input()`、GUI(Tk/Qt)。 +- 资源:内存 256MB、CPU 1 核、单文件 ≤100MB、命令超时默认 60s / 最大 120s。数据大就分块或采样。 +- 预装免装:pandas、numpy、matplotlib、seaborn、scipy、openpyxl、xlsxwriter;缺库时尽量改用预装库、标准库或纯本地实现。 +- 不可用:GPU(torch/CUDA)、交互输入 `input()`、GUI(Tk/Qt)。 diff --git a/src/backend/prompts/prompt_text/code_exec/system/10_tools_and_capabilities.system.md b/src/backend/prompts/prompt_text/code_exec/system/10_tools_and_capabilities.system.md index 384b9c68..10eb3a25 100644 --- a/src/backend/prompts/prompt_text/code_exec/system/10_tools_and_capabilities.system.md +++ b/src/backend/prompts/prompt_text/code_exec/system/10_tools_and_capabilities.system.md @@ -16,7 +16,7 @@ - 读/改/写文本文件 → `Read`/`Edit`/`Write`(不要走 bash 的 cat/sed/echo)。改或覆盖已存在文件前**必须先完整 `Read`**。 - 找文件/搜内容 → `Glob`/`Grep`(默认 `/workspace`;不要走 bash 的 find/grep)。 -- 跑脚本/系统命令、删移沙盒临时文件 → `bash`(用 `rm`/`mv`)。沙盒无网络,缺库时改用预装库或纯本地实现,不要尝试联网安装。 +- 跑脚本/系统命令、删移沙盒临时文件 → `bash`(用 `rm`/`mv`)。 - 简单算术或已知答案 → 直接回答,不调工具。 ### 工具消歧(多个工具看似都能干同一件事时,按此优先级,别摇摆) diff --git a/src/backend/prompts/prompt_text/default/system/00_role.system.md b/src/backend/prompts/prompt_text/default/system/00_role.system.md index 1418cdf6..144b0323 100644 --- a/src/backend/prompts/prompt_text/default/system/00_role.system.md +++ b/src/backend/prompts/prompt_text/default/system/00_role.system.md @@ -1,5 +1,5 @@ ## 身份 -你是 HugAgentOS 智能助手,专注经济运行、工业发展、产业分析领域的信息检索与分析。 +你是 HugAgentOS 智能助手。 ## 核心原则 **所有回答必须基于本次对话中工具实际返回的数据,不依赖模型预训练知识推断或补全。** diff --git a/src/backend/prompts/prompt_text/default/system/20_tools.system.md b/src/backend/prompts/prompt_text/default/system/20_tools.system.md index 99b2c960..cf1b9d60 100644 --- a/src/backend/prompts/prompt_text/default/system/20_tools.system.md +++ b/src/backend/prompts/prompt_text/default/system/20_tools.system.md @@ -19,19 +19,13 @@ **第三步:多工具协同。** 需要不同类型数据时(如同时需要数仓数据和知识库文档),可分别调用不同工具后整合。但**同一工具不要重复调用**——具备内部问题分解能力的工具必须将完整问题一次性传入,禁止拆分为多次调用;其它工具可以按需将问题分解。 -**第四步:兜底。** MCP 工具和技能都不足以回答时,才使用 `internet_search`。 ### 技能加载规则 - 技能不是工具——**绝对不要**把技能名称当作 function call 的函数名调用 - * 例如,使用技能cn-web-search技能时,不能将cn-web-search作为工具名直接调用,而应当调用view_text_file工具加载技能路径 - 加载方式:`view_text_file(file_path="")` -- 加载后按 SKILL.md 指令执行,技能通常会指定调用哪个 MCP 工具、传什么参数 +- 加载后按 SKILL.md 指令执行,技能通常会指定调用哪个工具、传什么参数 - 优先加载与用户当前目标最直接相关的技能;复合任务可按阶段加载多个技能,但每次加载后都要先按该技能说明完成对应阶段 -### `internet_search` 与搜索类技能的区别 -- `internet_search`:通用互联网搜索,适合简单的查询或作为兜底 -- 搜索类技能(如"中文网页搜索"):针对特定场景优化的多引擎聚合搜索,通过 `web_fetch` 调用专门的搜索引擎 URL,效果远优于 `internet_search` -- **凡是技能能覆盖的搜索场景,一律走技能,不走 `internet_search`** ### 数据优先级 内部数据(数据库、知识库) > 外部数据(互联网)。冲突时以高优先级为准并注明差异。 diff --git a/src/backend/scripts/_loop_convergence_unit.py b/src/backend/scripts/_loop_convergence_unit.py index cc6b291c..f03727aa 100644 --- a/src/backend/scripts/_loop_convergence_unit.py +++ b/src/backend/scripts/_loop_convergence_unit.py @@ -1,13 +1,13 @@ -"""Deterministic unit check: exit paths of the requirement ledger + read-only review sub-agent (no script verification, no numeric score). - -After the refactor, every card the loop flips relies on the driver-spawned read-only review sub-agent -(review_requirement) personally verifying the real output — and done must also pass an independent second -review. This test does not run a real LLM/sandbox/git: it stubs out the worker iteration, requirement -decomposition, reviewer, sandbox read/write and git, feeds only fixed verdicts, and verifies three exits: -all-passed completed / a single requirement exhausting its attempts blocked→budget_exhausted / done rejected -by the second review not flipping the card. Completes in <1s. - -Run: docker exec hugagent-backend python -m scripts._loop_convergence_unit +"""Deterministic unit check: exit paths of the requirement ledger + read-only review sub-agent. + +驱动器 v2(去预算化 + 规划器侦察/重规划 + 混合验收)后的收敛冒烟:不跑真实 +LLM/沙箱/git,桩掉 worker/规划/评审/沙箱,验证三条出口: + A. 全部需求通过 → completed(无机检需求翻牌仍需二次复核); + B. 单需求连续无推进 → stalls 到停滞上限 → blocked →(重规划桩返回 None)→ + budget_exhausted 部分完成; + C. done 被二次复核驳回 → 不翻牌,直至 blocked。 +完成 <1s。Run: docker exec hugagent-backend python -m scripts._loop_convergence_unit +(正式回归见 tests/orchestration/test_autonomous_loop_driver.py) """ import asyncio @@ -16,12 +16,19 @@ from orchestration.loop_evaluator import CONTINUE, DONE, GoalSpec +class _StubPolicy: + version = "unit" + strategy_change_after = 2 + max_attempts_per_requirement = 6 + budget_multiplier = 1.0 + + async def _fake_worker(**kwargs): return {"text": "stub work", "tokens": 10, "tool_calls": 1} def _make_fake_review(verdicts): - """Return preset verdicts in call order; done items carry non-empty evidence (otherwise the driver downgrades them to continue).""" + """按调用顺序回放判定;done 带非空证据(否则 driver 会降级为 continue)。""" calls = {"i": 0} async def _fake_review(**kwargs): @@ -30,12 +37,13 @@ async def _fake_review(**kwargs): v = verdicts[min(i, len(verdicts) - 1)] return {"verdict": v, "criteria_hit": ["stub"], "evidence": "reviewer 读到 /proj/index.html 含目标内容" if v == DONE else "", + "progress": False, "feedback": "stub 反馈"} return _fake_review, calls -def _fake_decompose_n(n): +def _fake_plan_n(n): async def _fake(**kwargs): return [{"id": f"R{i}", "description": f"stub 需求{i}"} for i in range(1, n + 1)] return _fake @@ -57,62 +65,80 @@ async def _noop_read_file(path, **kwargs): return "" # → _read_ledger returns None → fresh init every time +async def _noop_scout(**kwargs): + return "" + + +async def _noop_replan(**kwargs): + return None + + +async def _worktree_clean(*a, **kwargs): + return False + + def _patch_common(): al._run_worker_iteration = _fake_worker al._sbx_exec = _sbx_noop al._write_file = _noop_write_file al._read_file = _noop_read_file + al._git_worktree_changed = _worktree_clean al.extract_acceptance_criteria = _noop_criteria + al.scout_workspace = _noop_scout + al.replan_remaining = _noop_replan + al._resolve_loop_policy = lambda **kw: _StubPolicy() + + import core.services.ontology_service as osvc + + osvc.build_user_ontology_runtime = lambda **kw: ( + False, {"enabled": False, "packs": [], "review_level": "none"}) + + +_MAX_ATTEMPTS = _StubPolicy.max_attempts_per_requirement async def main() -> None: _patch_common() - # ── Scenario A: 3 requirements, review returns done on the 2nd try for each (continue first), and - # all done pass the second review → all-passed completed. - # Per requirement: worker→review(continue) in round 1; worker→review(done)+confirm(done) flips in round 2. - al.decompose_requirements = _fake_decompose_n(3) - # Sequence (driver calls review in order per requirement; after done it calls confirm): - # R1: continue, done, confirm-done → passed (3 calls) - # R2: same; R3: same + # ── Scenario A: 3 需求,每条第 2 轮 done 且二次复核通过 → completed。 + al.plan_requirements = _fake_plan_n(3) seq = [] for _ in range(3): - seq += [CONTINUE, DONE, DONE] # DONE immediately followed by confirm's DONE + seq += [CONTINUE, DONE, DONE] # done 后紧跟二次复核的 done al.review_requirement, _ = _make_fake_review(seq) resA = await run_autonomous_loop( loop_id="convA", user_id="unit", goal_spec=GoalSpec(objective="stub", acceptance_criteria=["c"]), - budget=LoopBudget(max_iters=20, max_wall_clock_s=60, max_tokens=10_000_000), + budget=LoopBudget(), # 默认不限预算 session_id="loop-convA", ) print(f"[A] status={resA.status} iters={resA.iterations} final={resA.final_score} reason={resA.reason}") assert resA.status == "completed", resA.status assert resA.final_score == 1.0, resA.final_score - # ── Scenario B: 1 requirement that always returns continue → attempts exhausted (_MAX_ATTEMPTS_PER_REQ) → blocked → budget_exhausted. - al.decompose_requirements = _fake_decompose_n(1) + # ── Scenario B: 1 需求永远 continue(无推进)→ stalls 到上限 → blocked → budget_exhausted。 + al.plan_requirements = _fake_plan_n(1) al.review_requirement, _ = _make_fake_review([CONTINUE]) resB = await run_autonomous_loop( loop_id="convB", user_id="unit", goal_spec=GoalSpec(objective="stub2", acceptance_criteria=["c"]), - budget=LoopBudget(max_iters=50, max_wall_clock_s=60, max_tokens=10_000_000), + budget=LoopBudget(), session_id="loop-convB", ) print(f"[B] status={resB.status} iters={resB.iterations} final={resB.final_score}") assert resB.status == "budget_exhausted", resB.status - assert resB.iterations == al._MAX_ATTEMPTS_PER_REQ, resB.iterations + assert resB.iterations == _MAX_ATTEMPTS, resB.iterations assert resB.final_score == 0.0, resB.final_score - # ── Scenario C: review reports done but the **second review rejects** (confirm=continue) → card not flipped, until attempts are exhausted and blocked. - al.decompose_requirements = _fake_decompose_n(1) - # Each round: review→DONE, confirm→CONTINUE (rejected) → not passed. The sequence alternates. + # ── Scenario C: done 被二次复核驳回 → 不翻牌,直至 blocked。 + al.plan_requirements = _fake_plan_n(1) al.review_requirement, _ = _make_fake_review([DONE, CONTINUE]) resC = await run_autonomous_loop( loop_id="convC", user_id="unit", goal_spec=GoalSpec(objective="stub3", acceptance_criteria=["c"]), - budget=LoopBudget(max_iters=50, max_wall_clock_s=60, max_tokens=10_000_000), + budget=LoopBudget(), session_id="loop-convC", ) print(f"[C] status={resC.status} iters={resC.iterations}") assert resC.status == "budget_exhausted", "done 被二次复核驳回不应翻牌" - assert resC.iterations == al._MAX_ATTEMPTS_PER_REQ, resC.iterations + assert resC.iterations == _MAX_ATTEMPTS, resC.iterations print("CONVERGENCE_UNIT_OK") diff --git a/src/backend/tests/chat/test_stream_event_builders.py b/src/backend/tests/chat/test_stream_event_builders.py index 3caa3b47..dbc3ca6f 100644 --- a/src/backend/tests/chat/test_stream_event_builders.py +++ b/src/backend/tests/chat/test_stream_event_builders.py @@ -1,7 +1,7 @@ """Lock the SSE event-builder output shared by the chat route and the background run executor (core.chat.tool_log). -These three builders were extracted from the two near-identical SSE loops in +These builders are shared by the two near-identical SSE loops in ``api/routes/v1/chats.py`` and ``orchestration/chat_run_executor.py``. The tests pin the exact event dicts + log side-effects so the two call sites stay byte-identical and a future change can't silently diverge them. @@ -9,7 +9,9 @@ from core.chat.tool_log import ( build_thinking_event, + build_tool_call_delta_event, build_tool_call_event, + build_tool_call_start_event, build_tool_result_event, ) @@ -31,6 +33,40 @@ def test_thinking_event_message_fallback(): } +def test_tool_call_start_and_delta_are_transient_wire_events(): + start = build_tool_call_start_event( + { + "tool_name": "bash", + "tool_display_name": "Bash", + "tool_id": "t1", + }, + "c1", + ) + assert start == { + "type": "tool_call_start", + "tool_name": "bash", + "tool_display_name": "Bash", + "tool_id": "t1", + "chat_id": "c1", + } + + delta = build_tool_call_delta_event( + { + "tool_name": "bash", + "tool_id": "t1", + "arguments_delta": '{"command":"ec', + }, + "c1", + ) + assert delta == { + "type": "tool_call_delta", + "tool_name": "bash", + "tool_id": "t1", + "arguments_delta": '{"command":"ec', + "chat_id": "c1", + } + + def test_tool_call_event_and_log_upsert(): log: list = [] chunk = { diff --git a/src/backend/tests/llm/test_subagent_tool_streaming.py b/src/backend/tests/llm/test_subagent_tool_streaming.py new file mode 100644 index 00000000..ce630561 --- /dev/null +++ b/src/backend/tests/llm/test_subagent_tool_streaming.py @@ -0,0 +1,52 @@ +"""Streaming argument coverage for nested sub-agent tool calls.""" + +import core.llm.subagent_tool as subagent_mod +from core.llm.subagent_tool import _SubMapper + + +class ToolCallStartEvent: # noqa: D101 - name-dispatched by _SubMapper + def __init__(self, tid="nested-1", name="bash"): + self.tool_call_id = tid + self.tool_call_name = name + + +class ToolCallDeltaEvent: # noqa: D101 + def __init__(self, delta, tid="nested-1"): + self.tool_call_id = tid + self.delta = delta + + +class ToolCallEndEvent: # noqa: D101 + def __init__(self, tid="nested-1"): + self.tool_call_id = tid + + +def test_subagent_tool_arguments_stream_then_finish_once(monkeypatch): + monkeypatch.setattr(subagent_mod, "_TOOL_CALL_DELTA_FLUSH_CHARS", 8) + monkeypatch.setattr(subagent_mod, "_TOOL_CALL_DELTA_FLUSH_INTERVAL_S", 999.0) + mapper = _SubMapper() + + output = mapper.feed(ToolCallStartEvent()) + output += mapper.feed(ToolCallDeltaEvent('{"command"')) + output += mapper.feed(ToolCallDeltaEvent(':"echo ok"}')) + output += mapper.feed(ToolCallEndEvent()) + + starts_and_final = [item for item in output if item["sub_type"] == "tool_call"] + assert starts_and_final == [ + { + "sub_type": "tool_call", + "tool_id": "nested-1", + "tool_name": "bash", + "input": None, + "status": "running", + }, + { + "sub_type": "tool_call", + "tool_id": "nested-1", + "tool_name": "bash", + "input": {"command": "echo ok"}, + "status": "running", + }, + ] + deltas = [item["arguments_delta"] for item in output if item["sub_type"] == "tool_call_delta"] + assert "".join(deltas) == '{"command":"echo ok"}' diff --git a/src/backend/tests/orchestration/streaming_tool_call_dedupe_selftest.py b/src/backend/tests/orchestration/streaming_tool_call_dedupe_selftest.py index a55a6175..c2b4be68 100644 --- a/src/backend/tests/orchestration/streaming_tool_call_dedupe_selftest.py +++ b/src/backend/tests/orchestration/streaming_tool_call_dedupe_selftest.py @@ -1,146 +1,102 @@ -"""Selftest: StreamingAgent should emit exactly one tool_call per tool_id -even when AgentScope feeds many partial chunks (MiniMax/Qwen stream long -tool_call args as 100s of cumulative chunks). - -Repro setup: - - Feed N-1 msgs with is_last=False, each carrying a ToolUseBlock with the - same tool_id and progressively-larger input. - - Then feed 1 msg with is_last=True (the "final" form of the same tool_use). - - Then feed a system msg with a matching ToolResultBlock, is_last=True. - -Expected events: - - 0 tool_call events while is_last=False - - Exactly 1 tool_pending event when the partial stream starts - - Exactly 1 tool_call event when is_last=True arrives (with final args) - - Exactly 1 tool_result event after the ToolResultBlock msg - -Run: - PYTHONPATH=src/backend python -m tests.streaming_tool_call_dedupe_selftest +"""Standalone regression check for streamed tool-call arguments. + +The May 2026 downgrade swallowed every partial argument event after an older +adapter repeatedly emitted cumulative tool payloads and created hundreds of +cards. The current contract is stricter: one start event opens a card, batched +deltas update it by stable ``tool_id``, and one completed call supplies parsed +arguments. + +Run from the repository root: + PYTHONPATH=src/backend python -m tests.orchestration.streaming_tool_call_dedupe_selftest """ from __future__ import annotations import asyncio -from typing import Any, List, Tuple -from unittest.mock import AsyncMock, MagicMock +import json +from types import SimpleNamespace +from typing import Any + + +class ToolCallStartEvent: # name-dispatched by StreamingAgent._map_event + def __init__(self, tool_id: str, tool_name: str) -> None: + self.tool_call_id = tool_id + self.tool_call_name = tool_name + + +class ToolCallDeltaEvent: + def __init__(self, tool_id: str, delta: str) -> None: + self.tool_call_id = tool_id + self.delta = delta + + +class ToolCallEndEvent: + def __init__(self, tool_id: str) -> None: + self.tool_call_id = tool_id + + +async def _collect(events: list[Any]) -> list[tuple[str, Any]]: + import orchestration.streaming as streaming_mod + from orchestration.streaming import StreamingAgent + + async def reply_stream(inputs=None): # noqa: ANN001, ARG001 + for event in events: + yield event + + state = SimpleNamespace( + user_id="tester", + chat_id="dedupe_case", + apply_request_context=lambda ctx, text: None, + context=SimpleNamespace(extend=lambda messages: None), + ) + agent = SimpleNamespace(state=state, model=None, reply_stream=reply_stream) + + # Make the character threshold deterministic for this standalone test; the + # production time threshold remains covered by the pytest suite. + streaming_mod._TOOL_CALL_DELTA_FLUSH_CHARS = 256 + streaming_mod._TOOL_CALL_DELTA_FLUSH_INTERVAL_S = 999.0 + + output: list[tuple[str, Any]] = [] + streamer = StreamingAgent(agent, mcp_clients=[]) + async for item in streamer.stream(session_messages=[], context={"enable_thinking": True}): + output.append(item) + return output def main() -> int: - try: - from orchestration.streaming import StreamingAgent - from agentscope.message import Msg - from agentscope.message._message_block import ToolUseBlock, ToolResultBlock - except ModuleNotFoundError as e: - print(f"streaming_tool_call_dedupe_selftest: SKIP (missing dependency: {e})") - return 0 - - HEREDOC_FULL = "echo " + ("x" * 4000) - PARTIAL_CHUNKS = 50 # enough to demonstrate the storm; real MiniMax produced 589 - TOOL_ID = "call_repro_001" - TOOL_NAME = "bash" - - async def _run() -> List[Tuple[str, Any]]: - # Build a fake agent: minimal surface, just enough for StreamingAgent. - agent = MagicMock() - agent._disable_console_output = True - agent._jx_context = None - agent._instance_pre_reply_hooks = {} - agent.memory = MagicMock() - agent.memory.add = AsyncMock() - - # Real asyncio.Queue so the stream loop polls it the same way as production. - queue: asyncio.Queue = asyncio.Queue(maxsize=200) - agent.msg_queue = queue - agent.set_msg_queue_enabled = MagicMock() - - async def _push_sequence(_user_msg: Any) -> Any: - """Mimic AgentScope's _reasoning loop: many partial chunks + a final - is_last=True chunk, then a tool_result msg.""" - # Partial chunks: build cumulative input growing toward the full - # heredoc. Same tool_id throughout (matches OpenAI streaming spec). - for i in range(PARTIAL_CHUNKS): - progress = int(len(HEREDOC_FULL) * (i + 1) / (PARTIAL_CHUNKS + 1)) - partial_input = {"cmd": HEREDOC_FULL[:progress]} - msg = Msg(name="agent", role="assistant", content=[ - ToolUseBlock(type="tool_use", id=TOOL_ID, name=TOOL_NAME, input=partial_input), - ]) - await queue.put((msg, False, None)) # is_last=False - await asyncio.sleep(0) # yield to consumer - - # Final chunk with complete args, is_last=True. - final_msg = Msg(name="agent", role="assistant", content=[ - ToolUseBlock(type="tool_use", id=TOOL_ID, name=TOOL_NAME, input={"cmd": HEREDOC_FULL}), - ]) - await queue.put((final_msg, True, None)) - - # tool_result msg (system role). - result_msg = Msg(name="system", role="system", content=[ - ToolResultBlock(type="tool_result", id=TOOL_ID, name=TOOL_NAME, output=[{"text": "ok"}]), - ]) - await queue.put((result_msg, True, None)) - - # Final assistant text msg signalling "done" (no tool_use). - done_msg = Msg(name="agent", role="assistant", content="done") - await queue.put((done_msg, True, None)) - return done_msg - - agent.reply = _push_sequence - - streamer = StreamingAgent(agent, mcp_clients=[]) - events: List[Tuple[str, Any]] = [] - async for ev in streamer.stream( - session_messages=[{"role": "user", "content": "run it"}], - context={"chat_id": "dedupe_case", "user_id": "tester"}, - ): - events.append(ev) - - return events - - events = asyncio.run(_run()) - - # Filter to only the event types we care about. - tool_calls = [(k, v) for k, v in events if k == "tool_call"] - tool_pendings = [(k, v) for k, v in events if k == "tool_pending"] - tool_results = [(k, v) for k, v in events if k == "tool_result"] - - fail = False - - if len(tool_calls) != 1: - print(f"FAIL: expected exactly 1 tool_call event, got {len(tool_calls)}") - for ev in tool_calls[:3]: - print(f" {ev!r}") - fail = True - else: - final_args = tool_calls[0][1].get("args", {}) - if final_args.get("cmd") != HEREDOC_FULL: - print(f"FAIL: tool_call args not the final form (len={len(final_args.get('cmd', ''))} vs {len(HEREDOC_FULL)})") - fail = True - - # We expect at least 1 tool_pending (during the partial stream). - if len(tool_pendings) < 1: - print(f"FAIL: expected >= 1 tool_pending event, got {len(tool_pendings)}") - fail = True - elif tool_pendings[0][1].get("reason") != "tool_args_streaming": - print(f"FAIL: tool_pending reason should be 'tool_args_streaming', got {tool_pendings[0][1]!r}") - fail = True - - if len(tool_results) != 1: - print(f"FAIL: expected exactly 1 tool_result event, got {len(tool_results)}") - fail = True - - if fail: - print(f"\nAll events ({len(events)} total):") - for k, v in events: - preview = repr(v) - if len(preview) > 120: - preview = preview[:117] + "..." - print(f" {k}: {preview}") + tool_id = "call_repro_001" + tool_name = "bash" + full_json = json.dumps({"command": "echo " + ("x" * 4000)}) + upstream = [ToolCallStartEvent(tool_id, tool_name)] + upstream.extend(ToolCallDeltaEvent(tool_id, char) for char in full_json) + upstream.append(ToolCallEndEvent(tool_id)) + + events = asyncio.run(_collect(upstream)) + starts = [payload for kind, payload in events if kind == "tool_call_start"] + deltas = [payload for kind, payload in events if kind == "tool_call_delta"] + calls = [payload for kind, payload in events if kind == "tool_call"] + + errors: list[str] = [] + if starts != [{"name": tool_name, "id": tool_id}]: + errors.append(f"expected one stable start event, got {starts!r}") + if "".join(item["delta"] for item in deltas) != full_json: + errors.append("batched deltas did not reconstruct the original argument JSON") + max_expected_deltas = (len(full_json) + 255) // 256 + if len(deltas) > max_expected_deltas: + errors.append( + f"delta batching regressed: {len(deltas)} events for {len(full_json)} characters" + ) + if len(calls) != 1 or calls[0].get("args") != json.loads(full_json): + errors.append(f"expected one completed parsed call, got {calls!r}") + + if errors: + for error in errors: + print(f"FAIL: {error}") return 1 print( - f"streaming_tool_call_dedupe_selftest: OK " - f"(partial_chunks={PARTIAL_CHUNKS}, tool_calls={len(tool_calls)}, " - f"tool_pendings={len(tool_pendings)}, tool_results={len(tool_results)})" + "streaming_tool_call_dedupe_selftest: OK " + f"(argument_chars={len(full_json)}, delta_events={len(deltas)}, tool_cards=1)" ) return 0 diff --git a/src/backend/tests/orchestration/test_autonomous_loop_driver.py b/src/backend/tests/orchestration/test_autonomous_loop_driver.py new file mode 100644 index 00000000..405ca9d7 --- /dev/null +++ b/src/backend/tests/orchestration/test_autonomous_loop_driver.py @@ -0,0 +1,336 @@ +"""自主循环驱动器 v2 行为回归(不跑真实 LLM/沙箱/git,全部桩替换,<1s)。 + +覆盖本轮 harness 改造的行为契约: + 1. 去预算化:LoopBudget 默认不限,循环跑到账本全通过为止; + 2. 混合验收:check_cmd 由 driver 亲自执行——机检过=免二次复核(收官需求除外), + 机检挂=不烧评审 agent; + 3. per-iteration 异常隔离:worker 抛异常只废本轮(不计 attempt),连续多轮才熔断 failed; + 4. 停滞告警(strategy_change)看 stalls 而非 attempts:健康推进的多轮大需求不再被 + 怂恿「换根本不同的方法」; + 5. 评审反馈按需求隔离:翻牌后不泄漏给下一条需求; + 6. blocked 触发重规划(replan_remaining),重拆后继续而非直接「部分完成」收场; + 7. steering:用户运行中追加指令注入下一轮 worker prompt; + 8. 收尾交付轮:部分完成收场时用现有成果跑一轮 wrap-up。 +""" +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from typing import Any, Dict, List + +import pytest + +import orchestration.autonomous_loop as al +from orchestration.autonomous_loop import LoopBudget, run_autonomous_loop +from orchestration.loop_evaluator import CONTINUE, DONE, GoalSpec + + +def _run(coro): + return asyncio.get_event_loop_policy().new_event_loop().run_until_complete(coro) + + +class StubPolicy: + version = "test" + strategy_change_after = 2 + max_attempts_per_requirement = 3 + budget_multiplier = 1.0 + + +class Harness: + """把 driver 的全部外部依赖换成可编排的桩,并记录 worker prompt / 评审调用。""" + + def __init__(self, monkeypatch, requirements: List[Dict[str, Any]]): + self.prompts: List[str] = [] + self.review_calls: List[Dict[str, Any]] = [] + self.review_script: List[Dict[str, Any]] = [] + self.worker_script: List[Any] = [] # dict 结果或 Exception + self.check_exit: Dict[str, int] = {} # check_cmd → exit code + self.worktree_changed = False + + async def fake_worker(**kw): + self.prompts.append(kw.get("prompt", "")) + item = self.worker_script.pop(0) if self.worker_script else { + "text": "干完了", "tokens": 10, "tool_calls": 1} + if isinstance(item, Exception): + raise item + return dict(item) + + async def fake_review(**kw): + self.review_calls.append({"second_pass": kw.get("second_pass", False), + "requirement_id": kw.get("requirement_id"), + "machine_evidence": kw.get("machine_evidence", "")}) + if self.review_script: + return dict(self.review_script.pop(0)) + return {"verdict": DONE, "criteria_hit": [], "evidence": "亲读 /workspace/out.md", + "progress": True, "feedback": "OK"} + + async def fake_sbx(cmd, **kw): + for c, code in self.check_exit.items(): + if c in cmd: + return (code, "check-output", "") + return (0, "", "") + + async def fake_scout(**kw): + return "现状:空工作区" + + async def fake_plan(**kw): + return [dict(r) for r in requirements] + + async def fake_replan(**kw): + return None + + async def fake_worktree_changed(*a, **kw): + return self.worktree_changed + + async def _noop_write(path, content, **kw): + return None + + async def _noop_read(path, **kw): + return "" + + async def _criteria(**kw): + return ["标准1"] + + monkeypatch.setattr(al, "_run_worker_iteration", fake_worker) + monkeypatch.setattr(al, "review_requirement", fake_review) + monkeypatch.setattr(al, "_sbx_exec", fake_sbx) + monkeypatch.setattr(al, "_git_worktree_changed", fake_worktree_changed) + monkeypatch.setattr(al, "_write_file", _noop_write) + monkeypatch.setattr(al, "_read_file", _noop_read) + monkeypatch.setattr(al, "extract_acceptance_criteria", _criteria) + monkeypatch.setattr(al, "scout_workspace", fake_scout) + monkeypatch.setattr(al, "plan_requirements", fake_plan) + monkeypatch.setattr(al, "replan_remaining", fake_replan) + monkeypatch.setattr(al, "_resolve_loop_policy", + lambda **kw: StubPolicy()) + # 退避 sleep 提速 + monkeypatch.setattr(al, "asyncio", SimpleNamespace( + sleep=self._fast_sleep, gather=asyncio.gather, + CancelledError=asyncio.CancelledError)) + # ontology 解析不碰 DB + import core.services.ontology_service as osvc + + monkeypatch.setattr(osvc, "build_user_ontology_runtime", + lambda **kw: (False, {"enabled": False, "packs": [], + "review_level": "none"})) + self.monkeypatch = monkeypatch + + @staticmethod + async def _fast_sleep(_s): + return None + + def go(self, *, budget: LoopBudget = None, poll_steering=None, + objective: str = "写一个产品页") -> Any: + return _run(run_autonomous_loop( + loop_id="t1", user_id="unit", + goal_spec=GoalSpec(objective=objective, acceptance_criteria=["c1"]), + budget=budget or LoopBudget(), + session_id="loop-t1", + poll_steering=poll_steering, + )) + + +@pytest.fixture() +def mk(monkeypatch): + def _mk(requirements): + return Harness(monkeypatch, requirements) + return _mk + + +# ── 1+2. 去预算化 + 混合验收 ──────────────────────────────────────────────── +def test_check_cmd_pass_skips_second_review_except_final(mk): + """R1 机检过 → 单次评审翻牌(无二次复核);R2 是收官需求 → 仍要二次复核。""" + h = mk([ + {"id": "R1", "description": "建骨架", "check_cmd": "CHECK_OK_1"}, + {"id": "R2", "description": "填内容", "check_cmd": "CHECK_OK_2"}, + ]) + h.check_exit = {"CHECK_OK_1": 0, "CHECK_OK_2": 0} + h.review_script = [ + {"verdict": DONE, "evidence": "e1", "progress": True, "feedback": ""}, # R1 单评审 + {"verdict": DONE, "evidence": "e2", "progress": True, "feedback": ""}, # R2 评审 + {"verdict": DONE, "evidence": "e3", "progress": True, "feedback": ""}, # R2 二次复核 + ] + res = h.go() + assert res.status == "completed", res.reason + r1_calls = [c for c in h.review_calls if c["requirement_id"] == "R1"] + r2_calls = [c for c in h.review_calls if c["requirement_id"] == "R2"] + assert len(r1_calls) == 1 and not r1_calls[0]["second_pass"] + assert len(r2_calls) == 2 and r2_calls[1]["second_pass"] + # 机检证据传给了评审员 + assert "CHECK_OK_1" in r1_calls[0]["machine_evidence"] + + +def test_check_cmd_fail_skips_reviewer_entirely(mk): + """机检挂 → 该轮完全不烧评审 agent,反馈来自命令输出;下一轮机检过再评审。""" + h = mk([{"id": "R1", "description": "字数达标", "check_cmd": "WC_CHECK"}]) + h.check_exit = {"WC_CHECK": 1} + h.worktree_changed = True # 有真实改动 → 算推进,stalls 不涨 + + res_holder = {} + + async def run_two_rounds(): + # 第一轮机检挂后把退出码翻成 0,让第二轮通过 + orig_sbx = al._sbx_exec + + async def flip_sbx(cmd, **kw): + code, out, err = await orig_sbx(cmd, **kw) + if "WC_CHECK" in cmd and h.check_exit["WC_CHECK"] == 1: + h.check_exit["WC_CHECK"] = 0 # 下一次就过 + return (1, "还差 2 万字", "") + return (code, out, err) + + al._sbx_exec = flip_sbx + h.review_script = [ + {"verdict": DONE, "evidence": "e", "progress": True, "feedback": ""}, + {"verdict": DONE, "evidence": "e", "progress": True, "feedback": ""}, + ] + res_holder["res"] = await run_autonomous_loop( + loop_id="t2", user_id="unit", + goal_spec=GoalSpec(objective="o", acceptance_criteria=[]), + budget=LoopBudget(), session_id="loop-t2", + ) + + _run(run_two_rounds()) + res = res_holder["res"] + assert res.status == "completed", res.reason + # 机检挂的那轮没有任何评审调用:总评审次数 = 第二轮的 评审+二次复核(收官)= 2 + assert len(h.review_calls) == 2 + # 第二轮 worker prompt 带上了机检反馈 + assert any("机检未通过" in p for p in h.prompts[1:]) + + +def test_budget_defaults_unlimited(mk): + """默认预算不限:需求磨到第 8 轮才过也不会被旧 50 轮/6h 预算拦掉。""" + h = mk([{"id": "R1", "description": "磨"}]) + h.review_script = ( + [{"verdict": CONTINUE, "evidence": "", "progress": True, "feedback": "再磨"}] * 7 + + [{"verdict": DONE, "evidence": "e", "progress": True, "feedback": ""}] * 2 + ) + res = h.go() + assert res.status == "completed", (res.status, res.reason) + assert res.iterations == 8 + + +# ── 3. 异常隔离与熔断 ───────────────────────────────────────────────────── +def test_worker_exception_is_isolated_not_fatal(mk): + h = mk([{"id": "R1", "description": "x"}]) + h.worker_script = [ + RuntimeError("gateway 500"), + {"text": "ok", "tokens": 5, "tool_calls": 1}, + ] + h.review_script = [{"verdict": DONE, "evidence": "e", "progress": True, "feedback": ""}] * 2 + res = h.go() + assert res.status == "completed", res.reason + # 异常轮不计 attempt:history 里只有健康轮的评审记录 + assert all(r["verdict"] != "failed" for r in res.history) + + +def test_consecutive_infra_circuit_breaker(mk, monkeypatch): + monkeypatch.setenv("LOOP_MAX_CONSECUTIVE_INFRA", "3") + h = mk([{"id": "R1", "description": "x"}]) + h.worker_script = [RuntimeError("boom")] * 10 + res = h.go() + assert res.status == "failed" + assert "熔断" in res.reason + + +# ── 4. 停滞告警看 stalls ────────────────────────────────────────────────── +def test_strategy_change_follows_stalls_not_attempts(mk): + """连续 3 轮有实质推进(progress=True):即便 attempts 超过阈值,也不注入停滞告警。""" + h = mk([{"id": "R1", "description": "逐章写 20 章"}]) + h.review_script = ( + [{"verdict": CONTINUE, "evidence": "章节+1", "progress": True, "feedback": "继续写"}] * 3 + + [{"verdict": DONE, "evidence": "e", "progress": True, "feedback": ""}] * 2 + ) + res = h.go() + assert res.status == "completed" + assert not any("停滞告警" in p for p in h.prompts), "健康推进不应触发换思路告警" + + +def test_strategy_change_appears_after_stalls(mk): + h = mk([{"id": "R1", "description": "x"}]) + h.review_script = ( + [{"verdict": CONTINUE, "evidence": "", "progress": False, "feedback": "原地"}] * 2 + + [{"verdict": DONE, "evidence": "e", "progress": True, "feedback": ""}] * 2 + ) + res = h.go() + assert res.status == "completed" + # stalls 达到 strategy_change_after(2) 后的那轮 prompt 带停滞告警 + assert any("停滞告警" in p for p in h.prompts) + + +# ── 5. 反馈按需求隔离 ───────────────────────────────────────────────────── +def test_feedback_not_leaked_across_requirements(mk): + h = mk([ + {"id": "R1", "description": "a"}, + {"id": "R2", "description": "b"}, + ]) + h.review_script = [ + {"verdict": DONE, "evidence": "e", "progress": True, "feedback": "R1专属反馈XYZ"}, + {"verdict": DONE, "evidence": "e", "progress": True, "feedback": "R1复核反馈XYZ"}, + {"verdict": DONE, "evidence": "e", "progress": True, "feedback": ""}, + {"verdict": DONE, "evidence": "e", "progress": True, "feedback": ""}, + ] + res = h.go() + assert res.status == "completed" + r2_prompt = h.prompts[1] + assert "XYZ" not in r2_prompt, "上一需求的评审反馈泄漏进了下一需求的 prompt" + assert "已完成并通过评审" in r2_prompt # 交接退化为一句完成通告 + + +# ── 6. blocked → 重规划 ────────────────────────────────────────────────── +def test_replan_on_block_then_complete(mk, monkeypatch): + h = mk([{"id": "R1", "description": "错误方向"}]) + + async def fake_replan(**kw): + return [{"id": "N1", "description": "换个方向", "check_cmd": ""}] + + monkeypatch.setattr(al, "replan_remaining", fake_replan) + h.review_script = ( + # R1: 3 轮无推进 → stalls 到 max_attempts(3) → blocked → replan + [{"verdict": CONTINUE, "evidence": "", "progress": False, "feedback": "不对"}] * 3 + # N1: 一次过(评审 + 收官二次复核) + + [{"verdict": DONE, "evidence": "e", "progress": True, "feedback": ""}] * 2 + ) + res = h.go() + assert res.status == "completed", (res.status, res.reason) + assert any("换个方向" in p for p in h.prompts) + + +# ── 7. steering 注入 ───────────────────────────────────────────────────── +def test_steering_injected_into_next_prompt(mk): + h = mk([{"id": "R1", "description": "x"}]) + queue = [["改成暗色主题"]] + + def poll(): + return queue.pop(0) if queue else [] + + h.review_script = [{"verdict": DONE, "evidence": "e", "progress": True, "feedback": ""}] * 2 + res = h.go(poll_steering=poll) + assert res.status == "completed" + assert "改成暗色主题" in h.prompts[0] + assert "用户临时指令" in h.prompts[0] + + +# ── 8. 收尾交付轮 ──────────────────────────────────────────────────────── +def test_wrapup_runs_on_partial_completion(mk): + h = mk([ + {"id": "R1", "description": "能做的"}, + {"id": "R2", "description": "做不动的"}, + ]) + h.review_script = ( + [{"verdict": DONE, "evidence": "e", "progress": True, "feedback": ""}] * 2 # R1 评审+二次复核 + + [{"verdict": CONTINUE, "evidence": "", "progress": False, "feedback": "难"}] * 3 # R2 stalls 满 + ) + res = h.go() + assert res.status == "budget_exhausted" + assert "收尾交付" in h.prompts[-1], "部分完成收场应有 wrap-up 轮整合交付" + + +def test_wrapup_skipped_when_nothing_passed(mk): + h = mk([{"id": "R1", "description": "全程失败"}]) + h.review_script = [ + {"verdict": CONTINUE, "evidence": "", "progress": False, "feedback": "no"}] * 3 + res = h.go() + assert res.status == "budget_exhausted" + assert not any("收尾交付" in p for p in h.prompts) diff --git a/src/backend/tests/orchestration/test_model_progress_signal.py b/src/backend/tests/orchestration/test_model_progress_signal.py index 80afafdd..4403230c 100644 --- a/src/backend/tests/orchestration/test_model_progress_signal.py +++ b/src/backend/tests/orchestration/test_model_progress_signal.py @@ -1,10 +1,9 @@ -"""model_progress liveness signal tests. +"""Tool-call delta batching and model_progress liveness tests. -During long tool-call-argument generation the model streams ToolCallDeltaEvent -for minutes while StreamingAgent._map_event yields nothing downstream; the run -inactivity watchdog then saw pure silence and killed a healthy run. stream() -now emits a throttled ("model_progress", None) when upstream events keep -arriving but none maps to an SSE event. +Small argument fragments are buffered briefly to avoid an SSE storm. While a +fragment has not crossed either flush threshold, ``model_progress`` still keeps +the run watchdog alive; flushed fragments become visible ``tool_call_delta`` +events and the completed JSON is emitted exactly once as ``tool_call``. """ import asyncio @@ -32,6 +31,11 @@ def __init__(self, tid="t1", delta="x"): self.delta = delta +class ToolCallEndEvent: # noqa: D101 + def __init__(self, tid="t1"): + self.tool_call_id = tid + + def _fake_agent(events): async def reply_stream(inputs=None): for ev in events: @@ -55,12 +59,12 @@ async def _collect(agent): @pytest.mark.asyncio -async def test_swallowed_deltas_emit_model_progress(monkeypatch): +async def test_small_buffered_deltas_emit_model_progress_until_flushed(monkeypatch): monkeypatch.setattr(streaming_mod, "_MODEL_PROGRESS_MIN_INTERVAL_S", 0.0) events = [ToolCallStartEvent()] + [ToolCallDeltaEvent(delta="chunk") for _ in range(5)] out = await _collect(_fake_agent(events)) types = [t for t, _ in out] - assert "tool_pending" in types + assert "tool_call_start" in types assert types.count("model_progress") == 5 @@ -73,6 +77,25 @@ async def test_throttle_suppresses_model_progress(): assert types.count("model_progress") == 0 +@pytest.mark.asyncio +async def test_tool_arguments_stream_as_bounded_deltas_then_one_final_call(monkeypatch): + monkeypatch.setattr(streaming_mod, "_TOOL_CALL_DELTA_FLUSH_CHARS", 8) + monkeypatch.setattr(streaming_mod, "_TOOL_CALL_DELTA_FLUSH_INTERVAL_S", 999.0) + events = [ + ToolCallStartEvent(), + ToolCallDeltaEvent(delta='{"command"'), + ToolCallDeltaEvent(delta=':"echo ok"}'), + ToolCallEndEvent(), + ] + out = await _collect(_fake_agent(events)) + + assert out[0] == ("tool_call_start", {"name": "bash", "id": "t1"}) + deltas = [payload["delta"] for kind, payload in out if kind == "tool_call_delta"] + assert "".join(deltas) == '{"command":"echo ok"}' + calls = [payload for kind, payload in out if kind == "tool_call"] + assert calls == [{"name": "bash", "args": {"command": "echo ok"}, "id": "t1"}] + + def _slow_model_agent(pre_events, silence_s): """Agent that emits ``pre_events`` then goes silent (model call hanging).""" diff --git a/src/backend/tests/orchestration/test_stale_reaper.py b/src/backend/tests/orchestration/test_stale_reaper.py index 494a668b..241c1617 100644 --- a/src/backend/tests/orchestration/test_stale_reaper.py +++ b/src/backend/tests/orchestration/test_stale_reaper.py @@ -246,6 +246,55 @@ async def test_finalize_run_wins_on_live_run(reaper_env): assert _get_run(session_factory, "run_live").status == "completed" +# ─── 自主循环豁免年龄硬顶(去预算化配套) ────────────────────────────────────── + + +async def test_hard_expired_loop_with_live_task_survives(reaper_env, monkeypatch): + """自主循环的使命是跑到任务完成为止:进程内 task 存活的 loop run 即使超过 + CHAT_RUN_HARD_MAX_AGE_SEC 也不得按年龄硬杀(历史竞态:6h 硬顶 == loop 旧默认 + 预算,跑满预算的健康 loop 在优雅收尾前被硬顶抢杀)。""" + session_factory, _ = reaper_env + _insert_run( + session_factory, + "run_loop_live", + age_sec=executor._HARD_MAX_AGE_SEC + 3600, + kind="autonomous_loop", + ) + + task = asyncio.create_task(asyncio.sleep(3600)) + monkeypatch.setitem(executor._active_runs, "run_loop_live", task) + + assert await executor.reap_stale_runs() == 0 + assert _get_run(session_factory, "run_loop_live").status == "running" + assert not task.cancelled() + task.cancel() + + +async def test_hard_expired_orphan_loop_reaped_by_quiet_rule_only(reaper_env): + """孤儿 loop(进程重启遗留、无活跃 task)不按年龄硬杀,但静默判据照常清理。""" + session_factory, fake_redis = reaper_env + _insert_run( + session_factory, + "run_loop_orphan", + age_sec=executor._HARD_MAX_AGE_SEC + 3600, + kind="autonomous_loop", + ) + # 流最近还在写 → 不是僵尸,跳过 + fake_redis.seed(executor._stream_key("run_loop_orphan"), _now_ms() - 5_000) + assert await executor.reap_stale_runs() == 0 + assert _get_run(session_factory, "run_loop_orphan").status == "running" + + # 流静默超阈值 → 按「无活动」清理(而非年龄硬顶),错误文案是 stalled + fake_redis.seed( + executor._stream_key("run_loop_orphan"), + _now_ms() - int(executor._STALE_QUIET_SEC * 1000) - 60_000, + ) + assert await executor.reap_stale_runs() == 1 + run = _get_run(session_factory, "run_loop_orphan") + assert run.status == "failed" + assert "stalled" in run.error_message + + async def test_is_run_cancelled_true_for_any_terminal_status(reaper_env): session_factory, _ = reaper_env for status, expected in [ diff --git a/src/backend/tests/services/test_loop_service_harness.py b/src/backend/tests/services/test_loop_service_harness.py new file mode 100644 index 00000000..6c08ee37 --- /dev/null +++ b/src/backend/tests/services/test_loop_service_harness.py @@ -0,0 +1,108 @@ +"""LoopService harness 增强回归:启动参数持久化 / steering 队列 / 中断归位。 + +对应 harness 改造:续跑不丢参(agent_loops.extra_data.start_params)、运行中追加 +指令(steering 队列,driver 每轮取走清空)、进程重启后 running 孤儿归位 interrupted。 +""" +from __future__ import annotations + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from core.db.engine import Base +from core.services.loop_service import LoopService + + +@pytest.fixture() +def db(): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(engine) + session = sessionmaker(bind=engine)() + yield session + session.close() + engine.dispose() + + +def _mk_loop(db): + return LoopService(db).create_loop( + user_id="u1", title="t", + goal_spec={"objective": "写个页面"}, + budget={}, + chat_id="c1", + ) + + +def test_start_params_roundtrip(db): + loop = _mk_loop(db) + svc = LoopService(db) + svc.save_start_params(loop.loop_id, { + "model_name": "m-big", + "model_provider_id": "prov-1", + "evaluator_model": None, # None/空值不落盘 + "worker_max_iters": 20, + "hitl_enabled": False, + "chat_mode": "high", + }) + got = svc.get_start_params(loop.loop_id) + assert got["model_name"] == "m-big" + assert got["model_provider_id"] == "prov-1" + assert got["worker_max_iters"] == 20 + assert got["chat_mode"] == "high" + assert "evaluator_model" not in got + + # 二次保存整体覆盖(不残留旧键) + svc.save_start_params(loop.loop_id, {"model_name": "m2"}) + got2 = svc.get_start_params(loop.loop_id) + assert got2 == {"model_name": "m2"} + + +def test_start_params_missing_loop(db): + assert LoopService(db).get_start_params("loop_nope") == {} + + +def test_steering_queue_consume_clears(db): + loop = _mk_loop(db) + svc = LoopService(db) + assert svc.push_steering(loop.loop_id, "改暗色主题") + assert svc.push_steering(loop.loop_id, " 标题换成中文 ") + assert not svc.push_steering(loop.loop_id, " ") # 空指令拒绝 + + got = svc.consume_steering(loop.loop_id) + assert got == ["改暗色主题", "标题换成中文"] + # 取走即清空 + assert svc.consume_steering(loop.loop_id) == [] + + +def test_steering_queue_caps_at_ten(db): + loop = _mk_loop(db) + svc = LoopService(db) + for i in range(14): + svc.push_steering(loop.loop_id, f"指令{i}") + got = svc.consume_steering(loop.loop_id) + assert len(got) == 10 + assert got[0] == "指令4" and got[-1] == "指令13" # 只留最近 10 条 + + +def test_mark_interrupted_only_running(db): + loop = _mk_loop(db) + svc = LoopService(db) + # created 状态不动 + svc.mark_interrupted(loop.loop_id, reason="重启") + assert svc.get_loop(loop.loop_id).status == "created" + + svc.mark_running(loop.loop_id) + svc.mark_interrupted(loop.loop_id, reason="服务重启导致运行中断") + got = svc.get_loop(loop.loop_id) + assert got.status == "interrupted" + assert "重启" in (got.result_summary or "") + + # 终态不被二次改写 + got.status = "completed" + db.commit() + svc.mark_interrupted(loop.loop_id) + assert svc.get_loop(loop.loop_id).status == "completed" diff --git a/src/backend/tests/services/test_turbo_capability_config.py b/src/backend/tests/services/test_turbo_capability_config.py new file mode 100644 index 00000000..ce61a5d6 --- /dev/null +++ b/src/backend/tests/services/test_turbo_capability_config.py @@ -0,0 +1,61 @@ +"""极速模式可装配能力(技能 / 插件)的共享配置解析。 + +覆盖 ``turbo.skill_ids`` / ``turbo.plugin_ids`` 的解析——逗号分隔、去空白、 +去重,配置层异常时按「没配」处理(不能因为读配置失败就把能力凭空塞进 +极速模式)。EE 控制面选项接口的覆盖位于独立的 EE-only 测试文件中。 +""" + +import pytest +from core.services import system_config as sysconf + + +class _StubConfigService: + def __init__(self, values: dict, *, raises: bool = False): + self._values = values + self._raises = raises + + def get(self, key, default=None): + if self._raises: + raise RuntimeError("config layer down") + return self._values.get(key, default) + + +def _patch_config(monkeypatch, values, *, raises=False): + stub = _StubConfigService(values, raises=raises) + monkeypatch.setattr(sysconf.SystemConfigService, "get_instance", classmethod(lambda cls: stub)) + + +@pytest.mark.parametrize( + "raw,expected", + [ + ("", ()), + (None, ()), + ("word-editing", ("word-editing",)), + (" a , b ,, a ", ("a", "b")), + ], +) +def test_turbo_skill_ids_parsing(monkeypatch, raw, expected): + _patch_config(monkeypatch, {"turbo.skill_ids": raw}) + assert sysconf.turbo_skill_ids() == expected + + +@pytest.mark.parametrize( + "raw,expected", + [ + ("", ()), + ("plugin-a@global", ("plugin-a@global",)), + ( + "plugin-a@global, plugin-b@global ,plugin-a@global", + ("plugin-a@global", "plugin-b@global"), + ), + ], +) +def test_turbo_plugin_ids_parsing(monkeypatch, raw, expected): + _patch_config(monkeypatch, {"turbo.plugin_ids": raw}) + assert sysconf.turbo_plugin_ids() == expected + + +def test_turbo_capability_ids_empty_when_config_layer_fails(monkeypatch): + _patch_config(monkeypatch, {}, raises=True) + assert sysconf.turbo_skill_ids() == () + assert sysconf.turbo_plugin_ids() == () diff --git a/src/backend/tests/test_yida_integration.py b/src/backend/tests/test_yida_integration.py index b6a0ed7e..2202f40b 100644 --- a/src/backend/tests/test_yida_integration.py +++ b/src/backend/tests/test_yida_integration.py @@ -1,7 +1,8 @@ -"""Unit tests for Yida (yida / openyida CLI) plugin integration: settings switch, login-state -persistent-volume degradation, path rules, marketplace plugin installability, SKILL.md host-adaptation -regression pins. No dependency on real Yida / a real sandbox — QR-scan login and CLI orchestration -are in-conversation behaviors, left for verification on a real machine.""" +"""Shared Yida plugin, connection service, path, and SKILL.md regression tests. + +No dependency on real Yida or a real sandbox. EE persistent-sandbox state tests +live in ``tests/sandbox/test_yida_ee_persistence.py``. +""" import pytest from sqlalchemy import create_engine @@ -31,19 +32,6 @@ def test_settings_yida_arch_flag(): assert settings.sandbox.yida_creds_bind_mount_enabled is True -# ── Login-state volume degradation ────────────────────────────────────── -def test_yida_volume_degrades_without_host_storage(): - from core.sandbox._opensandbox_internals import _make_yida_creds_volumes - # No local HOST_STORAGE_PATH → quietly return an empty list (the sandbox is still created; login state just doesn't persist across sessions) - assert _make_yida_creds_volumes("u1") == [] - - -def test_yida_volume_rejects_bad_user_id(): - from core.sandbox._opensandbox_internals import _make_yida_creds_volumes - assert _make_yida_creds_volumes("") == [] - assert _make_yida_creds_volumes("../etc/passwd") == [] - - def test_yida_cache_dir_path(): from core.sandbox._common import yida_cache_dir, yida_workspace_dir p = yida_cache_dir("u_abc") @@ -53,15 +41,6 @@ def test_yida_cache_dir_path(): assert yida_workspace_dir("u_abc") == p / "workspace" -def test_yida_workspace_mount_is_fixed(): - """Regression pin: the sandbox mount point must match the fixed working directory the SKILL.md - prescribes — the skill forces `cd /home/ubuntu/yida-workspace` before running openyida; if the - mount point changes, login state detaches from the persistent volume. cube inject/return and the - script-runner compose mount all reuse the same path.""" - from core.sandbox._opensandbox_internals import _YIDA_WORKSPACE_MOUNT - assert _YIDA_WORKSPACE_MOUNT == "/home/ubuntu/yida-workspace" - - def test_yida_shared_workspace_dir_path(): """script-runner shared sandbox working directory: one per deployment (__shared__), rooted alongside the per-user directories.""" from core.sandbox._common import yida_shared_workspace_dir @@ -71,68 +50,7 @@ def test_yida_shared_workspace_dir_path(): assert p.parent.parent.name == "yida_cache" -# ── Safe unpacking of cube return payloads ─────────────────────────────── -def _make_tar_b64(members: list[tuple[str, bytes]]) -> str: - import base64 - import io - import tarfile - - buf = io.BytesIO() - with tarfile.open(fileobj=buf, mode="w") as tar: - for name, data in members: - import time as _t - - info = tarfile.TarInfo(name=name) - info.size = len(data) - info.mtime = int(_t.time()) - tar.addfile(info, io.BytesIO(data)) - return base64.b64encode(buf.getvalue()).decode("ascii") - - -def test_cube_extract_yida_state_accepts_only_cache_json(tmp_path): - """cube return-payload unpacking whitelist: only write .cache/.json regular files; - reject path traversal, subdirectory smuggling and non-json members — the returned content comes - from the sandbox (a model-controllable environment) and must be treated as untrusted input.""" - from core.sandbox.cube_provider import CubeSandboxProvider - - raw = _make_tar_b64([ - (".cache/cookies-public.json", b'{"csrf_token":"x"}'), - (".cache/openyida-envs.json", b'{"current":"public"}'), - (".cache/../../etc/evil.json", b"pwn"), # path traversal - (".cache/sub/dir.json", b"nested"), # subdirectory smuggling - (".cache/notes.txt", b"txt"), # non-json - ("outside.json", b"outside"), # not under .cache/ - ]) - n = CubeSandboxProvider._extract_yida_state(raw, tmp_path) - assert n == 2 - assert (tmp_path / ".cache" / "cookies-public.json").read_bytes() == b'{"csrf_token":"x"}' - assert (tmp_path / ".cache" / "openyida-envs.json").exists() - # All traversal/smuggling members were rejected - extracted = sorted(p.name for p in (tmp_path / ".cache").iterdir()) - assert extracted == ["cookies-public.json", "openyida-envs.json"] - assert not (tmp_path.parent / "etc").exists() - - -def test_cube_extract_yida_state_rejects_symlink(tmp_path): - import io - import tarfile - import base64 - - buf = io.BytesIO() - with tarfile.open(fileobj=buf, mode="w") as tar: - info = tarfile.TarInfo(name=".cache/cookies-public.json") - info.type = tarfile.SYMTYPE - info.linkname = "/etc/passwd" - tar.addfile(info) - raw = base64.b64encode(buf.getvalue()).decode("ascii") - - from core.sandbox.cube_provider import CubeSandboxProvider - - assert CubeSandboxProvider._extract_yida_state(raw, tmp_path) == 0 - assert not (tmp_path / ".cache" / "cookies-public.json").exists() - - -# ── Connection panel service (yida_service: login executes via the sandbox, the cookie file is the source of truth) ── +# ── Connection panel service (cookie cache + real read-only liveness reconciliation) ── def test_yida_extract_result_json(): from core.services.yida_service import extract_result_json @@ -176,6 +94,72 @@ def test_yida_service_status_lifecycle(tmp_path, monkeypatch): assert svc.get_status("../etc")["status"] == "disconnected" +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("verdict", "expected_status"), + [("valid", "connected"), ("invalid", "disconnected"), ("unknown", "connected")], +) +async def test_yida_probe_status_reconciles_real_login( + tmp_path, monkeypatch, verdict, expected_status +): + """Only a definite server rejection disconnects; transient probe failures keep local state.""" + import json as _json + import os + + import core.services.yida_service as ys + + monkeypatch.setattr(ys, "_host_workspace_dir", lambda uid: tmp_path / uid / "workspace") + ys._PENDING.clear() + svc = ys.YidaService() + cache = tmp_path / "u1" / "workspace" / ".cache" + cache.mkdir(parents=True) + cookie_file = cache / "cookies-public.json" + cookie_file.write_text('{"cookies":[]}', encoding="utf-8") + first_mtime_ns = cookie_file.stat().st_mtime_ns + + async def fake_run(user_id, command, timeout): # noqa: ARG001 + assert command == ys._PROBE_COMMAND + return _json.dumps({"verdict": verdict}), 0 + + monkeypatch.setattr(svc, "_run_in_sandbox", fake_run) + result = await svc.probe_status("u1") + assert result["status"] == expected_status + assert cookie_file.exists() # an invalid probe marks this version stale; it does not destroy data + + if verdict == "valid": + meta = _json.loads((tmp_path / "u1" / "connection.json").read_text(encoding="utf-8")) + assert meta["status"] == "connected" + assert meta["last_verified_at"] + elif verdict == "invalid": + meta = _json.loads((tmp_path / "u1" / "connection.json").read_text(encoding="utf-8")) + assert meta["status"] == "disconnected" + assert meta["invalidated_cookie_mtime_ns"] == first_mtime_ns + assert svc.get_status("u1")["status"] == "disconnected" + + # A subsequent in-chat or panel QR login overwrites the cookie. The + # newer version becomes connected without requiring the old marker to + # be manually cleared. + cookie_file.write_text('{"cookies":[{"name":"new"}]}', encoding="utf-8") + os.utime(cookie_file, ns=(first_mtime_ns + 1_000_000, first_mtime_ns + 1_000_000)) + assert svc.get_status("u1")["status"] == "connected" + else: + assert not (tmp_path / "u1" / "connection.json").exists() + + +@pytest.mark.asyncio +async def test_yida_probe_without_cookie_does_not_start_sandbox(tmp_path, monkeypatch): + import core.services.yida_service as ys + + monkeypatch.setattr(ys, "_host_workspace_dir", lambda uid: tmp_path / uid / "workspace") + svc = ys.YidaService() + + async def should_not_run(*args, **kwargs): # noqa: ARG001 + raise AssertionError("a disconnected user must not start a probe sandbox") + + monkeypatch.setattr(svc, "_run_in_sandbox", should_not_run) + assert (await svc.probe_status("u1"))["status"] == "disconnected" + + @pytest.mark.asyncio async def test_yida_service_poll_transitions(tmp_path, monkeypatch): """Three poll states: ok → connected (metadata persisted); need_corp_selection → corp_selection; diff --git a/src/frontend/scripts/test-chat-stream-segments.ts b/src/frontend/scripts/test-chat-stream-segments.ts index d2b4cfea..3b89a72f 100644 --- a/src/frontend/scripts/test-chat-stream-segments.ts +++ b/src/frontend/scripts/test-chat-stream-segments.ts @@ -7,6 +7,7 @@ import { deferThinkingTextFragmentBeforeTool, restoreDeferredThinkingTextFragment, } from '../src/utils/streamSegments'; +import { extractCodeFromStreamingArgs } from '../src/utils/codeExecParser'; function tool(toolIndex: number): MessageSegment { return { type: 'tool', toolIndex }; @@ -89,4 +90,22 @@ function tool(toolIndex: number): MessageSegment { assert.deepEqual(segments, [tool(0), { type: 'text', content: '数' }, tool(1)]); } +{ + // A Write call is not valid JSON until its final delta, but escaped newlines + // must already render as real multi-line code in the folded tool preview. + const partial = '{"file_path":"src/demo.ts","content":"const a = 1;\\nconst b = 2;\\n'; + assert.deepEqual(extractCodeFromStreamingArgs('Write', partial), { + code: 'const a = 1;\nconst b = 2;\n', + language: 'typescript', + }); +} + +{ + const partial = '{"command":"printf \\"first\\\\nsecond\\"'; + assert.deepEqual(extractCodeFromStreamingArgs('bash', partial), { + code: 'printf "first\\nsecond"', + language: 'bash', + }); +} + console.log('chat stream segment tests passed'); diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index d0b2e425..6c6b7fb7 100755 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -107,6 +107,7 @@ export default function App() { refreshDeploymentMode(); }, [refreshDeploymentMode]); const canvasOpen = useCanvasStore((s) => s.isOpen); + const canvasFullscreen = useCanvasStore((s) => s.isFullscreen); const rightSidebarView = useCanvasStore((s) => s.activeView); const closeCanvas = useCanvasStore((s) => s.closeCanvas); const openRightSidebar = useCanvasStore((s) => s.openSidebar); @@ -689,7 +690,8 @@ export default function App() { onSelectSearchResult={handleSelectSearchResult} /> - + +
{!showChatHeader && (
)} - {panel === 'chat' && !isEmptyChat && ( - + {panel === 'chat' && !isEmptyChat && !canvasOpen && ( +
+ + + + +
{/* Global modals */} diff --git a/src/frontend/src/api.ts b/src/frontend/src/api.ts index df0e51cc..999b8e98 100644 --- a/src/frontend/src/api.ts +++ b/src/frontend/src/api.ts @@ -3200,8 +3200,8 @@ function _coerceYidaStatus(data: JsonObject): YidaStatus { }; } -export async function getYidaStatus(): Promise { - const wrapped = await apiRequest('/v1/integrations/yida/status'); +export async function getYidaStatus(probe = false): Promise { + const wrapped = await apiRequest(`/v1/integrations/yida/status${probe ? '?probe=true' : ''}`); return _coerceYidaStatus(unwrapData(wrapped)); } @@ -3263,7 +3263,7 @@ export async function getLoopIterations(loopId: string): Promise { return authFetch(`${getApiUrl()}/v1/loops/${encodeURIComponent(loopId)}/start`, { @@ -3276,7 +3276,7 @@ export async function startLoop( export async function resumeLoop( loopId: string, - body: { model_name?: string; evaluator_model?: string; worker_max_iters?: number; hitl_enabled?: boolean; enable_thinking?: boolean; chat_mode?: string } = {}, + body: { model_name?: string; model_provider_id?: string; evaluator_model?: string; worker_max_iters?: number; hitl_enabled?: boolean; enable_thinking?: boolean; chat_mode?: string } = {}, signal?: AbortSignal, ): Promise { return authFetch(`${getApiUrl()}/v1/loops/${encodeURIComponent(loopId)}/resume`, { @@ -3287,6 +3287,15 @@ export async function resumeLoop( }); } +/** 运行中追加一条用户指令:driver 下一轮 worker 开工前取走并以最高优先级注入 prompt。 */ +export async function steerLoop(loopId: string, message: string): Promise { + const wrapped = await apiRequest(`/v1/loops/${encodeURIComponent(loopId)}/steer`, { + method: 'POST', + body: JSON.stringify({ message }), + }); + return (unwrapData<{ queued: boolean }>(wrapped) || { queued: false }).queued; +} + export async function cancelLoop(loopId: string): Promise { const wrapped = await apiRequest(`/v1/loops/${encodeURIComponent(loopId)}/cancel`, { method: 'POST', diff --git a/src/frontend/src/components/canvas/CanvasPanel.tsx b/src/frontend/src/components/canvas/CanvasPanel.tsx index d31d6444..adccfd3f 100644 --- a/src/frontend/src/components/canvas/CanvasPanel.tsx +++ b/src/frontend/src/components/canvas/CanvasPanel.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, useCallback } from 'react'; import { - CloseOutlined, DownloadOutlined, ExpandOutlined, CompressOutlined, SaveOutlined, + DownloadOutlined, SaveOutlined, CheckOutlined, FileExclamationOutlined, } from '@ant-design/icons'; import { t } from '../../i18n'; @@ -10,6 +10,7 @@ import { useCanvasStore } from '../../stores/canvasStore'; import type { CanvasArtifact } from '../../stores/canvasStore'; import { UniverSpreadsheet } from './UniverSpreadsheet'; import { CitationMarkdownBlock } from '../citation'; +import { CanvasTabBar } from './CanvasTabBar'; import type { UniverSpreadsheetHandle } from './UniverSpreadsheet'; import { authFetch, overwriteFile } from '../../api'; import { @@ -47,13 +48,6 @@ function getFileIcon(artifact: CanvasArtifact) { return ; } -function formatSize(bytes?: number): string { - if (!bytes) return ''; - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / 1024 / 1024).toFixed(1)} MB`; -} - function previewErrorMessage(error: unknown, fallback: string): string { if (error instanceof PreviewFileTooLargeError) { return t('文件超过 {limit} 的安全预览上限,请下载后在本地打开', { @@ -364,7 +358,6 @@ function LargeFileRenderer({ export function CanvasPanel() { const { isOpen, activeView, artifact, closeCanvas, updateArtifact, openSeq } = useCanvasStore(); - const [expanded, setExpanded] = useState(false); const [dragWidth, setDragWidth] = useState(null); // Drag-resize in progress: kills the width transition (frame-accurate follow) // and mounts a full-screen transparent mask so iframes (PDF/HTML preview) @@ -534,23 +527,28 @@ export function CanvasPanel() { return (
{/* Drag handle */}
{/* 拖拽期间的全屏透明遮罩:防 iframe 吞 mousemove */} {dragging &&
} + {/* Header */}
{getFileIcon(artifact)}
- + {artifact.name} {isXlsx && xlsxDirty && ({t('已编辑')})} - {artifact.size && {formatSize(artifact.size)}}
@@ -569,12 +567,6 @@ export function CanvasPanel() { - -
diff --git a/src/frontend/src/components/canvas/CanvasTabBar.tsx b/src/frontend/src/components/canvas/CanvasTabBar.tsx new file mode 100644 index 00000000..9792b428 --- /dev/null +++ b/src/frontend/src/components/canvas/CanvasTabBar.tsx @@ -0,0 +1,72 @@ +import { + CloseOutlined, + FullscreenExitOutlined, + FullscreenOutlined, + InsertRowRightOutlined, +} from '@ant-design/icons'; +import { useEffect, type ReactNode } from 'react'; + +import { t } from '../../i18n'; +import { useCanvasStore } from '../../stores'; + +interface CanvasTabBarProps { + title: string; + icon: ReactNode; + closeLabel: string; + onClose: () => void; +} + +export function CanvasTabBar({ title, icon, closeLabel, onClose }: CanvasTabBarProps) { + const isFullscreen = useCanvasStore((state) => state.isFullscreen); + const setCanvasFullscreen = useCanvasStore((state) => state.setCanvasFullscreen); + const toggleCanvasFullscreen = useCanvasStore((state) => state.toggleCanvasFullscreen); + + useEffect(() => { + if (!isFullscreen) return undefined; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setCanvasFullscreen(false); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isFullscreen, setCanvasFullscreen]); + + return ( +
+
+ + {title} + +
+ + ); +} diff --git a/src/frontend/src/components/canvas/OntologySidebarPanel.tsx b/src/frontend/src/components/canvas/OntologySidebarPanel.tsx index 6878c4d1..0ed52f59 100644 --- a/src/frontend/src/components/canvas/OntologySidebarPanel.tsx +++ b/src/frontend/src/components/canvas/OntologySidebarPanel.tsx @@ -4,11 +4,13 @@ import { useCallback, useEffect, useRef } from 'react'; import { t } from '../../i18n'; import { useCanvasStore, useChatStore, useUIStore } from '../../stores'; import { OntologyRevisionPanel } from '../chat/OntologyRevisionPanel'; +import { CanvasTabBar } from './CanvasTabBar'; const AUTO_FOLLOW_THRESHOLD = 72; export function OntologySidebarPanel() { const target = useCanvasStore((state) => state.ontologyTarget); + const closeCanvas = useCanvasStore((state) => state.closeCanvas); const dispatchProcessVisible = useUIStore((state) => state.dispatchProcessVisible); const message = useChatStore((state) => { if (!target) return undefined; @@ -46,6 +48,12 @@ export function OntologySidebarPanel() { return (