Add task progress tool

This commit is contained in:
caoqianming 2026-06-08 08:44:16 +08:00
parent 15ecf45c93
commit 8616ba2b56
12 changed files with 333 additions and 1 deletions

View File

@ -50,6 +50,7 @@ from tools.seedance import SeedanceTool
from tools.seedream import SeedreamTool from tools.seedream import SeedreamTool
from tools.shell import ShellTool from tools.shell import ShellTool
from tools.skill_tool import LoadSkillTool from tools.skill_tool import LoadSkillTool
from tools.task_progress import TaskProgressTool
from tools.web_fetch import WebFetchTool from tools.web_fetch import WebFetchTool
from tools.web_search import WebSearchTool from tools.web_search import WebSearchTool
@ -426,6 +427,9 @@ def build_agent(
ur_path = user_root(workspace_dir, uid) ur_path = user_root(workspace_dir, uid)
tools = {} tools = {}
tp = TaskProgressTool(base_dir=tool_base, user_root=ur_path)
tools[tp.name] = tp
for cls in (ReadTool, WriteTool, EditTool, GlobTool, GrepTool, ShellTool): for cls in (ReadTool, WriteTool, EditTool, GlobTool, GrepTool, ShellTool):
t = cls(base_dir=tool_base, user_root=ur_path) t = cls(base_dir=tool_base, user_root=ur_path)
tools[t.name] = t tools[t.name] = t

View File

@ -45,7 +45,35 @@ def _message_chars(msg: dict[str, Any]) -> int:
return len(str(msg)) 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: if not isinstance(raw, str) or len(raw) <= max_chars:
return raw, False return raw, False
marker: dict[str, Any] = { marker: dict[str, Any] = {
@ -85,9 +113,11 @@ def _compact_assistant_tool_calls(
if not isinstance(fn, dict): if not isinstance(fn, dict):
continue continue
before = fn.get("arguments") before = fn.get("arguments")
tool_name = fn.get("name") if isinstance(fn.get("name"), str) else ""
after, did_compact = _compact_tool_call_arguments( after, did_compact = _compact_tool_call_arguments(
before, before,
max_chars=max(0, max_arg_chars), max_chars=max(0, max_arg_chars),
tool_name=tool_name,
) )
if did_compact: if did_compact:
fn["arguments"] = after fn["arguments"] = after
@ -152,6 +182,8 @@ def prepare_messages_with_stats(
if new_msg.get("name") == "load_skill": if new_msg.get("name") == "load_skill":
new_msg["content"] = _compact_load_skill_content(before) new_msg["content"] = _compact_load_skill_content(before)
compacted_skill_messages += int(new_msg["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: else:
new_msg["content"] = _compact_old_tool_content( new_msg["content"] = _compact_old_tool_content(
before, before,

View File

@ -6,6 +6,13 @@
- `shell` —— 执行命令(默认 60s 超时) - `shell` —— 执行命令(默认 60s 超时)
- `run_python` —— 在子进程里跑 Python (数据处理、生成 .pptx/.docx、画图等)。非短小一次性代码时,先用 `write``.py` 文件写到 task_dir,再用 `run_python(script_path="...")` 执行;避免大段源码进入对话历史。 - `run_python` —— 在子进程里跑 Python (数据处理、生成 .pptx/.docx、画图等)。非短小一次性代码时,先用 `write``.py` 文件写到 task_dir,再用 `run_python(script_path="...")` 执行;避免大段源码进入对话历史。
- `load_skill` —— 加载某个 skill 的完整指引 - `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 时该工具不会出现) ## 媒体生成工具(按需可用,未配置 ARK_API_KEY 时该工具不会出现)
- `seedream` —— 豆包图像生成。产物自动落 `<task_dir>/figures/`。每次 **¥0.22**(联网 `search=true` 加 ¥0.05)。 - `seedream` —— 豆包图像生成。产物自动落 `<task_dir>/figures/`。每次 **¥0.22**(联网 `search=true` 加 ¥0.05)。

View File

@ -9,6 +9,8 @@ description: 科学问题分析 / 拆解 / 引导。用户提出模糊的高层
**本 skill 不直接产出最终交付物**(不查文献 / 不写本子 / 不跑模型),只产出 `analysis.md` —— 一个共识性的"问题理解 + 路线图"文件。 **本 skill 不直接产出最终交付物**(不查文献 / 不写本子 / 不跑模型),只产出 `analysis.md` —— 一个共识性的"问题理解 + 路线图"文件。
进度展示建议:用 `task_progress` 标记「PICO 规范化 / Issue Tree 拆解 / 分支深化 / 实施路线图」四个阶段;用户确认卡点仍按正文工作流执行。
## 何时用 ## 何时用
- 用户说"我想搞清楚 / 想研究 / 不知道从哪入手 / 这个问题怎么拆 / 我们能做什么" - 用户说"我想搞清楚 / 想研究 / 不知道从哪入手 / 这个问题怎么拆 / 我们能做什么"

View File

@ -10,6 +10,7 @@ description: 修改、调试、实现代码相关任务。当用户要求修 bug
- 没有专属 scripts 或 templates,因为代码任务的多样性来自代码本身 - 没有专属 scripts 或 templates,因为代码任务的多样性来自代码本身
## 原则 ## 原则
- 多步骤代码任务适合用 `task_progress` 维护进度,推荐阶段:理解需求 → 定位代码 → 实现修改 → 运行验证 → 总结结果。
- **先看后改**: 用 grep/glob 定位,read 读出修改点的上下文,再 edit。盲改会出错。 - **先看后改**: 用 grep/glob 定位,read 读出修改点的上下文,再 edit。盲改会出错。
- **改动最小**: 只动必要行,不顺手重构、不改无关空白 - **改动最小**: 只动必要行,不顺手重构、不改无关空白
- **edit 唯一匹配**: old_str 必须在文件里出现且仅出现一次,不够唯一就多带上下文 - **edit 唯一匹配**: old_str 必须在文件里出现且仅出现一次,不够唯一就多带上下文

View File

@ -7,6 +7,8 @@ description: 生成 PowerPoint 演示文稿 (.pptx) 文件。✅ 触发:用户
把材料变成可演示的 .pptx。**先定调,再出稿,再验收** —— 不要一口气把整份 deck 丢出去。 把材料变成可演示的 .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** - `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` —— 画布尺寸 + 字号/配色/留白/字数预算等硬规则 - `references/design_principles.md` —— 画布尺寸 + 字号/配色/留白/字数预算等硬规则

View File

@ -7,6 +7,8 @@ description: 撰写中国科研项目申报书 / 课题任务书 (国家重点
把课题信息变成可提交的申报书 .docx。**先定基金类型 → 八条对齐 → 逐章起草 → 验收渲染** —— 不要一口气出全文。 把课题信息变成可提交的申报书 .docx。**先定基金类型 → 八条对齐 → 逐章起草 → 验收渲染** —— 不要一口气出全文。
进度展示建议:申报书任务用 `task_progress` 标记「摄取素材 / 基金类型与八条对齐 / 逐章起草 / 质量检查 / 渲染交付」等关键阶段;章节内的每段确认不需要都单独更新进度。
## 资源 ## 资源
下面所有路径都相对 **`<skill_dir>`** —— `load_skill` 返回头里的 `[skill=proposal, dir=<绝对路径>]`,用这个绝对路径拼脚本/资源,不要假设 cwd。 下面所有路径都相对 **`<skill_dir>`** —— `load_skill` 返回头里的 `[skill=proposal, dir=<绝对路径>]`,用这个绝对路径拼脚本/资源,不要假设 cwd。

View File

@ -150,6 +150,54 @@ class ContextCompactionTests(unittest.TestCase):
self.assertNotIn("A" * 100, tc["function"]["arguments"]) self.assertNotIn("A" * 100, tc["function"]["arguments"])
self.assertEqual(stats["compacted_tool_call_arguments"], 1) 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -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()

77
tools/task_progress.py Normal file
View File

@ -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=(",", ":"))

View File

@ -439,6 +439,33 @@
margin: 4px 0 0; padding: 8px; background: var(--code-bg); border-radius: var(--r-sm); 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; 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,折叠态也可见) */ /* media tool 摘要 banner(model / size / cost / elapsed,折叠态也可见) */
.tool-banner { .tool-banner {
display: inline-flex; flex-wrap: wrap; gap: 6px; display: inline-flex; flex-wrap: wrap; gap: 6px;

View File

@ -445,6 +445,80 @@ function setRunHint(run, text) {
if (state.taskId === run.taskId) $("chat-hint").textContent = 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) => `
<div class="tp-step ${escapeHtml(s.status)}">
<span class="tp-mark">${escapeHtml(mark(s.status))}</span>
<span class="tp-text">${escapeHtml(s.title)}</span>
</div>
`).join("");
return `<div class="task-progress"><div class="tp-title">进度</div><div class="tp-list">${rows}</div></div>`;
}
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) { function renderMessages(msgs) {
const wrap = $("chat-stream"); const wrap = $("chat-stream");
wrap.innerHTML = ""; wrap.innerHTML = "";
@ -482,6 +556,7 @@ function renderMessages(msgs) {
lastAsstModel = m.model_profile; lastAsstModel = m.model_profile;
} }
if (role === "tool") { if (role === "tool") {
if ((p.name || "") === "task_progress") continue;
// 嵌进上一个 assistant 的 tool_call(简化:直接独立显示) // 嵌进上一个 assistant 的 tool_call(简化:直接独立显示)
const card = document.createElement("div"); const card = document.createElement("div");
card.className = "msg tool"; card.className = "msg tool";
@ -516,6 +591,8 @@ function renderMessages(msgs) {
} }
if (Array.isArray(p.tool_calls) && p.tool_calls.length) { if (Array.isArray(p.tool_calls) && p.tool_calls.length) {
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir); const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
let progressSteps = [];
let sawProgress = false;
for (const tc of p.tool_calls) { for (const tc of p.tool_calls) {
const fn = (tc.function && tc.function.name) || "?"; const fn = (tc.function && tc.function.name) || "?";
let argsObj = {}; let argsObj = {};
@ -524,6 +601,11 @@ function renderMessages(msgs) {
argsObj = JSON.parse((tc.function && tc.function.arguments) || "{}"); argsObj = JSON.parse((tc.function && tc.function.arguments) || "{}");
args = JSON.stringify(argsObj, null, 2); args = JSON.stringify(argsObj, null, 2);
} catch (e) { args = (tc.function && tc.function.arguments) || ""; } } 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 label = toolActivityLabel(fn, argsObj);
const isProducer = ARTIFACT_PRODUCING_TOOLS.has(fn); const isProducer = ARTIFACT_PRODUCING_TOOLS.has(fn);
const rels = isProducer ? pickFresh(extractArtifactRels(args, wd)) : []; const rels = isProducer ? pickFresh(extractArtifactRels(args, wd)) : [];
@ -532,6 +614,7 @@ function renderMessages(msgs) {
${renderArtifactBarHtml(rels, isProducer)} ${renderArtifactBarHtml(rels, isProducer)}
`; `;
} }
if (sawProgress) html += renderProgressHtml(progressSteps);
} }
card.innerHTML = html; card.innerHTML = html;
highlightIn(card); highlightIn(card);
@ -942,6 +1025,11 @@ function handleSseEvent(ev, asstCard, ctx) {
const fn = (ev.data && ev.data.name) || "?"; const fn = (ev.data && ev.data.name) || "?";
const args = (ev.data && ev.data.args) || ""; const args = (ev.data && ev.data.args) || "";
const argsStr = typeof args === "string" ? args : JSON.stringify(args, null, 2); 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 label = toolActivityLabel(fn, args);
const det = document.createElement("details"); const det = document.createElement("details");
det.className = "tool-call"; det.className = "tool-call";
@ -962,6 +1050,7 @@ function handleSseEvent(ev, asstCard, ctx) {
const txt = (ev.data && ev.data.result) || ""; const txt = (ev.data && ev.data.result) || "";
const txtStr = typeof txt === "string" ? txt : JSON.stringify(txt, null, 2); const txtStr = typeof txt === "string" ? txt : JSON.stringify(txt, null, 2);
const toolName = (ev.data && ev.data.name) || ""; const toolName = (ev.data && ev.data.name) || "";
if (toolName === "task_progress") return;
const banner = extractMediaBanner(toolName, txtStr); const banner = extractMediaBanner(toolName, txtStr);
const det = document.createElement("details"); const det = document.createElement("details");
det.className = "tool-call"; det.className = "tool-call";