Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/backend/api/routes/v1/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,9 +383,22 @@ async def get_main_capabilities(
cfg = ModelConfigService.get_instance().resolve("main_agent")
supports = bool((cfg.extra if cfg else {}).get("supports_reasoning_effort"))
switch_enabled = bool(user and user_can_switch_model(db, user.user_id))
# Real context window (tokens) of the main chat model, so the frontend
# context-usage gauge reflects the model's true window instead of a guess.
# 0 when unconfigured — the frontend falls back to a heuristic/default.
main_context_length = int(cfg.context_length) if cfg and cfg.context_length else 0
# Tokens the backend reserves for the system prompt + skill/tool descriptions
# (ContextBudget.system_prompt_reserve). Surfaced so the gauge can count the
# (client-invisible) system prompt toward context usage rather than a placeholder.
from core.llm.context_manager import ContextBudget
system_prompt_tokens = ContextBudget.system_prompt_reserve
return success_response(
data={
"main_agent": {"supports_reasoning_effort": supports},
"main_agent": {
"supports_reasoning_effort": supports,
"context_length": main_context_length,
"system_prompt_tokens": system_prompt_tokens,
},
"user_model_switch": {
"enabled": switch_enabled,
"models": list_user_selectable_models(db) if switch_enabled else [],
Expand Down
38 changes: 20 additions & 18 deletions src/backend/core/llm/agent_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ async def create_agent_executor(
# top_level_chat: whether this construction is a "top-level interactive main
# conversation capable of hosting plan mode" — astream_chat_workflow passes
# True explicitly after determining (has chat_id, not
# channel/automation/batch/plan_chat). The enter_plan_mode tool is
# channel/automation/batch/plan_chat). The update_plan tool is
# registered ONLY on this positive signal. All derived/non-interactive paths
# (plan generation, plan-execute steps, subagents, batch, autonomous loop,
# channels, non-streaming…) default to False → they naturally never get the
Expand Down Expand Up @@ -1093,34 +1093,36 @@ def _build_toolkit() -> Toolkit:
len(visible_subagents),
)

# ── Register enter_plan_mode tool (top-level interactive main conversations only, positive opt-in) ──
# Lets the main agent proactively switch into plan mode when it judges a
# task complex enough (generate plan → user confirms → execute).
# ── Register update_plan tool (top-level interactive main conversations only, positive opt-in) ──
# Codex-style lightweight plan tracker: for complex tasks the main
# agent maintains a step checklist and keeps executing in the same
# turn (no redirect, no approval gate); the frontend renders it as a
# plan bar above the chat input. This replaced the old
# enter_plan_mode redirect for model-initiated planning.
# Recognizes ONLY the single positive signal top_level_chat (passed in
# by astream_chat_workflow after it determines this is an interactive
# main conversation) — not a negative exclusion list of "not batch and
# not plan_mode and not …". A negative list leaks the tool with every
# derived context it misses (historically, plan-execute steps,
# plan-generation disable_tools, and channel runs all leaked this way,
# producing "plan within plan" nesting); a positive opt-in has one
# single source of truth, and all derived/non-interactive constructions
# get nothing by default. The DB switch auto_plan_entry_enabled (which
# itself returns False on config-layer errors) can turn this off
# entirely.
# plan-generation disable_tools, and channel runs all leaked this way);
# a positive opt-in has one single source of truth, and all
# derived/non-interactive constructions get nothing by default. The DB
# switch auto_plan_entry_enabled (which itself returns False on
# config-layer errors) can turn this off entirely.
from core.services.system_config import auto_plan_entry_enabled

if top_level_chat and auto_plan_entry_enabled():
from core.llm.plan_entry_tool import (
build_enter_plan_prompt_section,
register_enter_plan_tool,
from core.llm.plan_update_tool import (
build_plan_update_prompt_section,
register_plan_update_tool,
)

register_enter_plan_tool(toolkit)
_ep_section = build_enter_plan_prompt_section()
if _ep_section:
system_prompt = system_prompt + "\n\n" + _ep_section
register_plan_update_tool(toolkit)
_pu_section = build_plan_update_prompt_section()
if _pu_section:
system_prompt = system_prompt + "\n\n" + _pu_section
_log.info(
"[factory] +%s enter_plan_mode tool registered (chat_id=%s)",
"[factory] +%s update_plan tool registered (chat_id=%s)",
_elapsed(),
chat_id,
)
Expand Down
91 changes: 0 additions & 91 deletions src/backend/core/llm/plan_entry_tool.py

This file was deleted.

126 changes: 126 additions & 0 deletions src/backend/core/llm/plan_update_tool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""update_plan tool — lightweight in-conversation plan/progress tracker for the main agent.

Codex-style ``update_plan``: for complex multi-step tasks the main agent
maintains a step checklist while it keeps working **in the same turn** — the
tool never interrupts or redirects the conversation. orchestration/workflow.py
intercepts the tool call and emits a ``plan_update`` SSE event; the frontend
renders it as a slim plan bar above the chat input (NOT as a chat message).

This replaces the old ``enter_plan_mode`` redirect for model-initiated
planning: no turn abort, no approval gate, no separate execution pipeline.
The manual plan mode (user-toggled, generate → approve → execute) is a
separate pipeline and is unaffected.
"""

from __future__ import annotations

import logging
from typing import Any, Dict, List, Optional

from agentscope.tool import Toolkit

# AgentScope 2.0: tool functions must return ToolChunk (call_tool rejects ToolResponse).
from agentscope.tool._response import ToolChunk as ToolResponse
from agentscope.message import TextBlock

logger = logging.getLogger(__name__)

_VALID_STATUSES = ("pending", "in_progress", "completed")


def parse_plan_update_args(tool_args: Any) -> Optional[Dict[str, Any]]:
"""Normalize raw update_plan tool args into a plan_update event payload.

Returns ``{"title": str, "steps": [{"title", "status"}]}`` or None when the
args are not yet complete/valid (streaming may deliver partial args).
Shared by the tool body and workflow.py's SSE interception so both sides
agree on what counts as a valid plan.
"""
if not isinstance(tool_args, dict):
return None
raw_steps = tool_args.get("steps")
if not isinstance(raw_steps, list) or not raw_steps:
return None
steps: List[Dict[str, str]] = []
for s in raw_steps:
if isinstance(s, str):
s = {"title": s}
if not isinstance(s, dict):
return None
step_title = str(s.get("title") or s.get("step") or "").strip()
if not step_title:
return None
status = str(s.get("status") or "pending").strip().lower()
if status not in _VALID_STATUSES:
status = "pending"
steps.append({"title": step_title, "status": status})
return {
"title": str(tool_args.get("title") or "").strip(),
"steps": steps,
}


def register_plan_update_tool(toolkit: Toolkit) -> None:
"""Register the update_plan tool into the main agent's toolkit.

The tool body only validates and echoes progress back to the LLM; the
user-facing side effect (the plan bar) is driven by workflow.py emitting
``plan_update`` from the tool-call arguments.
"""

async def update_plan(steps: list, title: str = "") -> ToolResponse:
"""维护当前复杂任务的分步计划清单(展示在用户输入框上方的计划栏)。

面对复杂、多步骤的任务时用它列出并更新执行计划:开始动手前先调用一次
列出全部步骤;每完成一步立即再次调用,更新各步骤的 status。调用本工具
**不会打断执行**——更新完计划后继续在本轮对话中正常执行任务即可。

Args:
steps (`list`):
完整的步骤列表(每次调用都传全量列表,不是增量)。每个元素为
``{"title": "步骤标题", "status": "pending|in_progress|completed"}``。
保持恰好一个步骤处于 in_progress;已完成的标记 completed。
title (`str`):
(可选)计划标题,简洁概括任务目标。

Returns:
`ToolResponse`:
当前进度确认。继续执行任务,不要停下等待。
"""
parsed = parse_plan_update_args({"title": title, "steps": steps})
if not parsed:
return ToolResponse(content=[TextBlock(
type="text",
text=(
"错误:steps 必须是非空列表,每个元素为 "
'{"title": "...", "status": "pending|in_progress|completed"}。'
),
)])
done = sum(1 for s in parsed["steps"] if s["status"] == "completed")
total = len(parsed["steps"])
logger.info("[update_plan] %d/%d steps completed (title=%s)",
done, total, parsed["title"][:60])
return ToolResponse(content=[TextBlock(
type="text",
text=f"计划已更新({done}/{total} 步完成)。请继续执行任务。",
)])

toolkit.register_tool_function(update_plan, namesake_strategy="skip")


def build_plan_update_prompt_section() -> str:
"""System-prompt fragment describing the update_plan tool (stable across turns, prefix-cache friendly)."""
return (
"## 任务计划清单(update_plan)\n\n"
"面对**复杂、多步骤**的任务(预计需要多次工具调用、跨多个文件/成果物、"
"或需要较长执行过程)时,用 `update_plan` 工具维护一份分步计划清单,"
"它会展示在用户输入框上方,让用户随时看到你的整体计划与进度:\n"
"- 开始动手前先调用一次,列出 3-8 个步骤(第一步 in_progress,其余 pending);\n"
"- 每完成一步**立即**再调用一次,传入全量步骤列表并更新各步 status,"
"保持恰好一个步骤处于 in_progress;\n"
"- 计划有变(需要增删/合并步骤)时同样通过 update_plan 更新全量列表;\n"
"- 对有可核验产出的任务(生成文件/代码/数据处理等),在计划末尾加一个"
"「验证与修正」步骤:核验产物是否达标,发现问题**直接修复并复验**,而不是只报告;\n"
"- 简单问答、单步操作、检索类请求**不要**使用。\n"
"调用 update_plan 不会打断你——更新后继续在本轮对话里正常执行任务。\n"
)
2 changes: 1 addition & 1 deletion src/backend/core/services/system_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,7 @@ def code_capability_enabled() -> bool:


def auto_plan_entry_enabled() -> bool:
"""Master switch for the main agent auto-entering plan mode (the enter_plan_mode tool).
"""Master switch for the main agent's in-conversation plan tracker (the update_plan tool).

Control source = the Config console "System config" DB value ``chat.auto_plan_entry_enable``,
defaulting to "true" (enabled by default). Ops can set it to false to disable the capability, ≤30s, no restart.
Expand Down
4 changes: 4 additions & 0 deletions src/backend/core/services/user_model_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ def list_user_selectable_models(db: Session) -> list[dict]:
"supports_reasoning_effort": bool(
(row.extra_config or {}).get("supports_reasoning_effort")
),
# Real context window (tokens) so the frontend can show accurate
# context-usage instead of guessing from the model name. May be 0 /
# missing when an admin has not filled it in — the caller falls back.
"context_length": int((row.extra_config or {}).get("context_length") or 0),
}
for row in rows
]
19 changes: 9 additions & 10 deletions src/backend/orchestration/chat_run_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -535,19 +535,18 @@ async def _emit(event: Dict[str, Any]) -> None:
}
)

elif chunk_type == "plan_redirect":
# The main agent called enter_plan_mode to switch into plan mode —
# workflow.py has already broken out of the current agent loop.
# Forward the to-be-planned task to the frontend, which drives the
# existing plan-mode pipeline (generate plan → preview card → user
# confirms → execute). Same human-in-the-loop gate as
# batch_confirm: the agent does not continue on its own; the user
# confirms on the plan card.
elif chunk_type == "plan_update":
# The main agent updated its lightweight plan checklist via the
# update_plan tool. Forward full-state to the frontend, which
# renders it as a plan bar above the chat input (not in the
# message flow). The agent keeps executing in the same turn —
# no approval gate, no loop abort.
await _emit(
{
"type": "plan_redirect",
"type": "plan_update",
"chat_id": chat_id,
"task_description": chunk.get("task_description", ""),
"title": chunk.get("title", ""),
"steps": chunk.get("steps", []),
}
)

Expand Down
5 changes: 5 additions & 0 deletions src/backend/orchestration/subagents/plan_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -1037,6 +1037,11 @@ def _cancellation_requested() -> bool:
step_text = re.sub(
r"<think>.*?</think>", "", step_text, flags=re.DOTALL
).strip()
# Orphan closing tag (server pre-fills the opening <think>, so the
# completion starts with bare reasoning): everything before the
# last </think> is reasoning, keep only the answer after it.
if "</think>" in step_text:
step_text = step_text.rsplit("</think>", 1)[-1].strip()

from core.ontology.validator import requires_output_review

Expand Down
Loading
Loading