diff --git a/core/agent_builder.py b/core/agent_builder.py index df53216..be12092 100644 --- a/core/agent_builder.py +++ b/core/agent_builder.py @@ -50,6 +50,7 @@ from tools.seedance import SeedanceTool from tools.seedream import SeedreamTool from tools.shell import ShellTool from tools.skill_tool import LoadSkillTool +from tools.task_progress import TaskProgressTool from tools.web_fetch import WebFetchTool from tools.web_search import WebSearchTool @@ -426,6 +427,9 @@ def build_agent( ur_path = user_root(workspace_dir, uid) tools = {} + tp = TaskProgressTool(base_dir=tool_base, user_root=ur_path) + tools[tp.name] = tp + for cls in (ReadTool, WriteTool, EditTool, GlobTool, GrepTool, ShellTool): t = cls(base_dir=tool_base, user_root=ur_path) tools[t.name] = t diff --git a/core/context.py b/core/context.py index ce743fa..44fc20b 100644 --- a/core/context.py +++ b/core/context.py @@ -45,7 +45,35 @@ def _message_chars(msg: dict[str, Any]) -> int: return len(str(msg)) -def _compact_tool_call_arguments(raw: Any, max_chars: int) -> tuple[Any, bool]: +def _compact_task_progress_arguments(raw: Any) -> tuple[Any, bool]: + if not isinstance(raw, str): + return raw, False + try: + parsed = json.loads(raw) + except Exception: + parsed = {} + if not isinstance(parsed, dict): + parsed = {} + marker: dict[str, Any] = { + "_compacted": True, + "tool": "task_progress", + "note": "old UI progress details omitted from context", + } + action = parsed.get("action") + if isinstance(action, str) and action: + marker["action"] = action + steps = parsed.get("steps") + if isinstance(steps, list): + marker["step_count"] = len(steps) + step = parsed.get("step") + if isinstance(step, dict) and isinstance(step.get("id"), str): + marker["step_id"] = step["id"] + return json.dumps(marker, ensure_ascii=False), True + + +def _compact_tool_call_arguments(raw: Any, max_chars: int, tool_name: str = "") -> tuple[Any, bool]: + if tool_name == "task_progress": + return _compact_task_progress_arguments(raw) if not isinstance(raw, str) or len(raw) <= max_chars: return raw, False marker: dict[str, Any] = { @@ -85,9 +113,11 @@ def _compact_assistant_tool_calls( if not isinstance(fn, dict): continue before = fn.get("arguments") + tool_name = fn.get("name") if isinstance(fn.get("name"), str) else "" after, did_compact = _compact_tool_call_arguments( before, max_chars=max(0, max_arg_chars), + tool_name=tool_name, ) if did_compact: fn["arguments"] = after @@ -152,6 +182,8 @@ def prepare_messages_with_stats( if new_msg.get("name") == "load_skill": new_msg["content"] = _compact_load_skill_content(before) compacted_skill_messages += int(new_msg["content"] != before) + elif new_msg.get("name") == "task_progress": + new_msg["content"] = "[task_progress updated; UI-only details omitted from context]" else: new_msg["content"] = _compact_old_tool_content( before, diff --git a/prompts/system/general_v1.md b/prompts/system/general_v1.md index f7e79ba..daa50fa 100644 --- a/prompts/system/general_v1.md +++ b/prompts/system/general_v1.md @@ -6,6 +6,13 @@ - `shell` —— 执行命令(默认 60s 超时) - `run_python` —— 在子进程里跑 Python (数据处理、生成 .pptx/.docx、画图等)。非短小一次性代码时,先用 `write` 把 `.py` 文件写到 task_dir,再用 `run_python(script_path="...")` 执行;避免大段源码进入对话历史。 - `load_skill` —— 加载某个 skill 的完整指引 +- `task_progress` —— 给 Web 前端发布/更新用户可见的进度步骤列表。只在多步骤任务使用;开始时设 3-7 个关键步骤,每完成或进入一个关键步骤时更新一次。 + +## 进度展示约定 +- 多步骤任务开始后,用 `task_progress(action="set_plan", steps=[...])` 发布一份简短计划。 +- 步骤标题面向用户,写“理解需求 / 实现功能 / 运行测试 / 输出结果”这类阶段,不要写每个文件读写或每次 shell 命令。 +- 只在关键状态变化时用 `update_step`:当前步骤进入 `in_progress`,完成后改 `completed`。不要高频更新,不要把普通 tool 调用都转成进度步骤。 +- 简单问答、单次文件读取、很小的改动不需要调用 `task_progress`。 ## 媒体生成工具(按需可用,未配置 ARK_API_KEY 时该工具不会出现) - `seedream` —— 豆包图像生成。产物自动落 `/figures/`。每次 **¥0.22**(联网 `search=true` 加 ¥0.05)。 diff --git a/skills/analyze/SKILL.md b/skills/analyze/SKILL.md index 28040ef..550dc73 100644 --- a/skills/analyze/SKILL.md +++ b/skills/analyze/SKILL.md @@ -9,6 +9,8 @@ description: 科学问题分析 / 拆解 / 引导。用户提出模糊的高层 **本 skill 不直接产出最终交付物**(不查文献 / 不写本子 / 不跑模型),只产出 `analysis.md` —— 一个共识性的"问题理解 + 路线图"文件。 +进度展示建议:用 `task_progress` 标记「PICO 规范化 / Issue Tree 拆解 / 分支深化 / 实施路线图」四个阶段;用户确认卡点仍按正文工作流执行。 + ## 何时用 - 用户说"我想搞清楚 / 想研究 / 不知道从哪入手 / 这个问题怎么拆 / 我们能做什么" diff --git a/skills/coding/SKILL.md b/skills/coding/SKILL.md index 9f1ca94..6711f76 100644 --- a/skills/coding/SKILL.md +++ b/skills/coding/SKILL.md @@ -10,6 +10,7 @@ description: 修改、调试、实现代码相关任务。当用户要求修 bug - 没有专属 scripts 或 templates,因为代码任务的多样性来自代码本身 ## 原则 +- 多步骤代码任务适合用 `task_progress` 维护进度,推荐阶段:理解需求 → 定位代码 → 实现修改 → 运行验证 → 总结结果。 - **先看后改**: 用 grep/glob 定位,read 读出修改点的上下文,再 edit。盲改会出错。 - **改动最小**: 只动必要行,不顺手重构、不改无关空白 - **edit 唯一匹配**: old_str 必须在文件里出现且仅出现一次,不够唯一就多带上下文 diff --git a/skills/ppt/SKILL.md b/skills/ppt/SKILL.md index 892fba9..255413f 100644 --- a/skills/ppt/SKILL.md +++ b/skills/ppt/SKILL.md @@ -7,6 +7,8 @@ description: 生成 PowerPoint 演示文稿 (.pptx) 文件。✅ 触发:用户 把材料变成可演示的 .pptx。**先定调,再出稿,再验收** —— 不要一口气把整份 deck 丢出去。 +进度展示建议:多页 deck 任务用 `task_progress` 标记「摄取素材 / 八条对齐 / 逐页生成 / 质量检查 / 交付」等关键阶段;不要把每一页的内部文件写入都作为进度步骤。 + ## 资源 - `scripts/pptx_helpers.py` —— **版式工具箱模块**:配色/字体常量 + `new_presentation`/`load`/`add_slide`/`set_palette` + `add_textbox`/`add_rect`/`add_dot`/`add_badge`/`page_title`/`apply_brand` 等 helper。每页 `import pptx_helpers as P` 调用,**不要把 helper 源码默写进 run_python** - `references/design_principles.md` —— 画布尺寸 + 字号/配色/留白/字数预算等硬规则 diff --git a/skills/proposal/SKILL.md b/skills/proposal/SKILL.md index 829463d..41f9c42 100644 --- a/skills/proposal/SKILL.md +++ b/skills/proposal/SKILL.md @@ -7,6 +7,8 @@ description: 撰写中国科研项目申报书 / 课题任务书 (国家重点 把课题信息变成可提交的申报书 .docx。**先定基金类型 → 八条对齐 → 逐章起草 → 验收渲染** —— 不要一口气出全文。 +进度展示建议:申报书任务用 `task_progress` 标记「摄取素材 / 基金类型与八条对齐 / 逐章起草 / 质量检查 / 渲染交付」等关键阶段;章节内的每段确认不需要都单独更新进度。 + ## 资源 下面所有路径都相对 **``** —— `load_skill` 返回头里的 `[skill=proposal, dir=<绝对路径>]`,用这个绝对路径拼脚本/资源,不要假设 cwd。 diff --git a/tests/test_context_compaction.py b/tests/test_context_compaction.py index dc74924..68164d2 100644 --- a/tests/test_context_compaction.py +++ b/tests/test_context_compaction.py @@ -150,6 +150,54 @@ class ContextCompactionTests(unittest.TestCase): self.assertNotIn("A" * 100, tc["function"]["arguments"]) self.assertEqual(stats["compacted_tool_call_arguments"], 1) + def test_compacts_old_task_progress_arguments_to_plan_marker(self) -> None: + args = json.dumps({ + "action": "set_plan", + "steps": [ + {"id": "s1", "title": "理解需求", "status": "completed"}, + {"id": "s2", "title": "实现功能", "status": "in_progress"}, + ], + }, ensure_ascii=False) + messages = [ + {"role": "system", "content": "rules"}, + { + "role": "assistant", + "content": None, + "tool_calls": [{ + "id": "tc1", + "type": "function", + "function": {"name": "task_progress", "arguments": args}, + }], + }, + ] + [{"role": "user", "content": f"recent {i}"} for i in range(12)] + + prepared, stats = prepare_messages_with_stats(messages) + + compacted_args = json.loads(prepared[1]["tool_calls"][0]["function"]["arguments"]) + self.assertTrue(compacted_args["_compacted"]) + self.assertEqual(compacted_args["tool"], "task_progress") + self.assertEqual(compacted_args["action"], "set_plan") + self.assertEqual(compacted_args["step_count"], 2) + self.assertNotIn("理解需求", prepared[1]["tool_calls"][0]["function"]["arguments"]) + self.assertEqual(stats["compacted_tool_call_arguments"], 1) + + def test_old_task_progress_tool_result_uses_tiny_marker(self) -> None: + messages = [ + {"role": "system", "content": "rules"}, + { + "role": "tool", + "tool_call_id": "tc1", + "name": "task_progress", + "content": json.dumps({"ok": True, "steps": [{"title": "A" * 2000}]}), + }, + {"role": "user", "content": "next"}, + ] + + prepared, stats = prepare_messages_with_stats(messages, keep_recent=1) + + self.assertEqual(prepared[1]["content"], "[task_progress updated; UI-only details omitted from context]") + self.assertEqual(stats["compacted_tool_messages"], 1) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_task_progress_tool.py b/tests/test_task_progress_tool.py new file mode 100644 index 0000000..fa1fc09 --- /dev/null +++ b/tests/test_task_progress_tool.py @@ -0,0 +1,41 @@ +import json +import unittest + +from tools.task_progress import TaskProgressTool + + +class TaskProgressToolTests(unittest.TestCase): + def test_schema_exposes_set_update_and_clear_actions(self) -> None: + schema = TaskProgressTool().schema + + fn = schema["function"] + self.assertEqual(fn["name"], "task_progress") + action_enum = fn["parameters"]["properties"]["action"]["enum"] + self.assertEqual(action_enum, ["set_plan", "update_step", "clear"]) + + def test_execute_returns_short_ui_only_result(self) -> None: + out = TaskProgressTool().execute( + action="set_plan", + steps=[ + {"id": "s1", "title": "理解需求", "status": "completed"}, + {"id": "s2", "title": "实现功能", "status": "in_progress"}, + ], + ) + + data = json.loads(out) + self.assertEqual(data, {"ok": True, "action": "set_plan", "step_count": 2}) + self.assertLess(len(out), 80) + + def test_execute_normalizes_update_step_without_echoing_title(self) -> None: + out = TaskProgressTool().execute( + action="update_step", + step={"id": "s2", "title": "实现功能", "status": "completed"}, + ) + + data = json.loads(out) + self.assertEqual(data, {"ok": True, "action": "update_step", "step_id": "s2"}) + self.assertNotIn("实现功能", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/task_progress.py b/tools/task_progress.py new file mode 100644 index 0000000..09894a6 --- /dev/null +++ b/tools/task_progress.py @@ -0,0 +1,77 @@ +"""UI-only task progress tool. + +The tool gives the model a structured way to publish a short user-visible plan. +Its result is intentionally tiny; the full plan stays in the assistant tool_call +arguments for Web rendering and is compacted out of older LLM context. +""" +from __future__ import annotations + +import json +from typing import Any + +from .base import Tool + + +class TaskProgressTool(Tool): + name = "task_progress" + description = ( + "Publish or update a concise user-visible progress checklist for the current task. " + "Use only for meaningful multi-step work: set the plan once, update a step when it " + "starts or completes, and clear it only when it is no longer relevant. This is a UI " + "progress signal, not a work product." + ) + parameters = { + "type": "object", + "additionalProperties": False, + "properties": { + "action": { + "type": "string", + "enum": ["set_plan", "update_step", "clear"], + "description": "set_plan replaces the checklist; update_step changes one step; clear removes it.", + }, + "steps": { + "type": "array", + "description": "Required for set_plan. Keep to 3-7 user-meaningful steps.", + "items": { + "type": "object", + "additionalProperties": False, + "properties": { + "id": {"type": "string", "description": "Stable short id, e.g. s1."}, + "title": {"type": "string", "description": "Short user-visible step title."}, + "status": { + "type": "string", + "enum": ["pending", "in_progress", "completed"], + }, + }, + "required": ["id", "title", "status"], + }, + }, + "step": { + "type": "object", + "description": "Required for update_step.", + "additionalProperties": False, + "properties": { + "id": {"type": "string"}, + "title": {"type": "string"}, + "status": { + "type": "string", + "enum": ["pending", "in_progress", "completed"], + }, + }, + "required": ["id", "status"], + }, + }, + "required": ["action"], + } + + def execute(self, **kwargs: Any) -> str: + action = str(kwargs.get("action") or "") + out: dict[str, Any] = {"ok": True, "action": action} + if action == "set_plan": + steps = kwargs.get("steps") + out["step_count"] = len(steps) if isinstance(steps, list) else 0 + elif action == "update_step": + step = kwargs.get("step") + if isinstance(step, dict) and step.get("id"): + out["step_id"] = str(step["id"]) + return json.dumps(out, ensure_ascii=False, separators=(",", ":")) diff --git a/web/static/dev.html b/web/static/dev.html index 6241813..3be0391 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -439,6 +439,33 @@ margin: 4px 0 0; padding: 8px; background: var(--code-bg); border-radius: var(--r-sm); overflow-x: auto; max-height: 300px; white-space: pre-wrap; } + .task-progress { + margin-top: 8px; padding: 8px 10px; + border: 1px solid var(--border-soft); border-radius: var(--r-md); + background: #fafafa; font-size: 12px; + } + .task-progress .tp-title { + margin-bottom: 5px; color: var(--muted); font-family: var(--mono); font-size: 11px; + } + .task-progress .tp-list { display: grid; gap: 4px; } + .task-progress .tp-step { + display: grid; grid-template-columns: 18px minmax(0, 1fr); + align-items: start; gap: 6px; min-height: 18px; + } + .task-progress .tp-mark { + width: 18px; height: 18px; border-radius: 50%; + display: inline-flex; align-items: center; justify-content: center; + border: 1px solid var(--border); background: #fff; color: var(--muted); + font-size: 11px; line-height: 1; + } + .task-progress .tp-step.completed .tp-mark { + color: #fff; background: var(--c-green); border-color: var(--c-green); + } + .task-progress .tp-step.in_progress .tp-mark { + color: var(--accent); border-color: var(--accent); background: var(--accent-soft); + } + .task-progress .tp-text { overflow-wrap: anywhere; line-height: 1.45; } + .task-progress .tp-step.completed .tp-text { color: var(--muted); text-decoration: line-through; } /* media tool 摘要 banner(model / size / cost / elapsed,折叠态也可见) */ .tool-banner { display: inline-flex; flex-wrap: wrap; gap: 6px; diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 635cc57..6988fd4 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -445,6 +445,80 @@ function setRunHint(run, text) { if (state.taskId === run.taskId) $("chat-hint").textContent = text; } +function normalizeProgressStatus(status) { + return ["pending", "in_progress", "completed"].includes(status) ? status : "pending"; +} + +function normalizeProgressStep(step) { + if (!step || typeof step !== "object") return null; + const id = String(step.id || "").trim(); + const title = String(step.title || "").trim(); + const status = normalizeProgressStatus(step.status); + if (!id || !title) return null; + return { id, title, status }; +} + +function applyProgressAction(progress, args) { + if (!args || typeof args !== "object") return progress; + const action = args.action || ""; + if (action === "clear") return []; + if (action === "set_plan") { + const steps = Array.isArray(args.steps) ? args.steps.map(normalizeProgressStep).filter(Boolean) : []; + return steps; + } + if (action === "update_step") { + const raw = args.step; + if (!raw || typeof raw !== "object") return progress; + const id = String(raw.id || "").trim(); + if (!id) return progress; + let found = false; + const next = progress.map((s) => { + if (s.id !== id) return s; + found = true; + return { + id: s.id, + title: String(raw.title || s.title || "").trim(), + status: normalizeProgressStatus(raw.status || s.status), + }; + }).filter(s => s.title); + if (!found && raw.title) { + next.push({ + id, + title: String(raw.title).trim(), + status: normalizeProgressStatus(raw.status), + }); + } + return next; + } + return progress; +} + +function renderProgressHtml(steps) { + if (!Array.isArray(steps) || !steps.length) return ""; + const mark = (status) => status === "completed" ? "✓" : (status === "in_progress" ? "…" : ""); + const rows = steps.map((s) => ` +
+ ${escapeHtml(mark(s.status))} + ${escapeHtml(s.title)} +
+ `).join(""); + return `
进度
${rows}
`; +} + +function renderProgressInto(card, steps) { + let el = card.querySelector(":scope > .task-progress"); + if (!Array.isArray(steps) || !steps.length) { + if (el) el.remove(); + return; + } + const html = renderProgressHtml(steps); + if (el) { + el.outerHTML = html; + } else { + card.insertAdjacentHTML("beforeend", html); + } +} + function renderMessages(msgs) { const wrap = $("chat-stream"); wrap.innerHTML = ""; @@ -482,6 +556,7 @@ function renderMessages(msgs) { lastAsstModel = m.model_profile; } if (role === "tool") { + if ((p.name || "") === "task_progress") continue; // 嵌进上一个 assistant 的 tool_call(简化:直接独立显示) const card = document.createElement("div"); card.className = "msg tool"; @@ -516,6 +591,8 @@ function renderMessages(msgs) { } if (Array.isArray(p.tool_calls) && p.tool_calls.length) { const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir); + let progressSteps = []; + let sawProgress = false; for (const tc of p.tool_calls) { const fn = (tc.function && tc.function.name) || "?"; let argsObj = {}; @@ -524,6 +601,11 @@ function renderMessages(msgs) { argsObj = JSON.parse((tc.function && tc.function.arguments) || "{}"); args = JSON.stringify(argsObj, null, 2); } catch (e) { args = (tc.function && tc.function.arguments) || ""; } + if (fn === "task_progress") { + progressSteps = applyProgressAction(progressSteps, argsObj); + sawProgress = true; + continue; + } const label = toolActivityLabel(fn, argsObj); const isProducer = ARTIFACT_PRODUCING_TOOLS.has(fn); const rels = isProducer ? pickFresh(extractArtifactRels(args, wd)) : []; @@ -532,6 +614,7 @@ function renderMessages(msgs) { ${renderArtifactBarHtml(rels, isProducer)} `; } + if (sawProgress) html += renderProgressHtml(progressSteps); } card.innerHTML = html; highlightIn(card); @@ -942,6 +1025,11 @@ function handleSseEvent(ev, asstCard, ctx) { const fn = (ev.data && ev.data.name) || "?"; const args = (ev.data && ev.data.args) || ""; const argsStr = typeof args === "string" ? args : JSON.stringify(args, null, 2); + if (fn === "task_progress") { + ctx.progressSteps = applyProgressAction(ctx.progressSteps || [], args); + renderProgressInto(asstCard, ctx.progressSteps); + return; + } const label = toolActivityLabel(fn, args); const det = document.createElement("details"); det.className = "tool-call"; @@ -962,6 +1050,7 @@ function handleSseEvent(ev, asstCard, ctx) { const txt = (ev.data && ev.data.result) || ""; const txtStr = typeof txt === "string" ? txt : JSON.stringify(txt, null, 2); const toolName = (ev.data && ev.data.name) || ""; + if (toolName === "task_progress") return; const banner = extractMediaBanner(toolName, txtStr); const det = document.createElement("details"); det.className = "tool-call";