Add safe conversational workspace rename

This commit is contained in:
caoqianming 2026-07-27 11:23:20 +08:00
parent 98713bb7fa
commit 85c918dc2f
15 changed files with 508 additions and 45 deletions

View File

@ -5,6 +5,10 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。 > 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.59.6 — 2026-07-27
- 现在可以直接在对话里让助手修改当前工作目录名。改名会在本轮回复结束后安全执行,并同步更新所有共享该目录的任务关联,不会再因直接移动目录导致后续对话找不到文件。
## 0.59.5 — 2026-07-27 ## 0.59.5 — 2026-07-27
- 视频生成现在支持让已有图片直接“动起来”:既可以指定一张图片作为视频开场画面,也可以同时提供多张人物、产品、场景或风格参考图(最多 9 张),生成时更容易保持主体和视觉风格一致。 - 视频生成现在支持让已有图片直接“动起来”:既可以指定一张图片作为视频开场画面,也可以同时提供多张人物、产品、场景或风格参考图(最多 9 张),生成时更容易保持主体和视觉风格一致。

View File

@ -197,7 +197,7 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
- working_dir 存相对 ROOT posix 串,读写统一过 `core/paths.py`;入口 `validate_task_name` 拒空/`/\NUL`/`.` 起头。 - working_dir 存相对 ROOT posix 串,读写统一过 `core/paths.py`;入口 `validate_task_name` 拒空/`/\NUL`/`.` 起头。
- **0004 简化**:runs 表只写不读、run_id 单活 run 下全冗余 → 合并 `run_status/run_error` 入 tasks。**0006**:`tasks.model_profile` 为 source-of-truth(PATCH 切、下条 send 生效);usage_events 重建 v2 多态形态,统计 source-of-truth;tasks 三列保留作粗概览。run_status 终态:ok 收回 idle,error(出错)与 cancelled(用户停止)是持久终态 —— 前端 `renderPersistedRunTerminal` 据此在每次重渲后补持久卡(扛过收尾 loadMessages 整屏重建),刷新/切任务仍在;下次起新 run(post_message 写 running)覆盖清掉。 - **0004 简化**:runs 表只写不读、run_id 单活 run 下全冗余 → 合并 `run_status/run_error` 入 tasks。**0006**:`tasks.model_profile` 为 source-of-truth(PATCH 切、下条 send 生效);usage_events 重建 v2 多态形态,统计 source-of-truth;tasks 三列保留作粗概览。run_status 终态:ok 收回 idle,error(出错)与 cancelled(用户停止)是持久终态 —— 前端 `renderPersistedRunTerminal` 据此在每次重渲后补持久卡(扛过收尾 loadMessages 整屏重建),刷新/切任务仍在;下次起新 run(post_message 写 running)覆盖清掉。
- **No-subtask**:同 user 下前缀互含即拒(归一 posix 后 Python 端比对);同 working_dir 允许。 - **No-subtask**:同 user 下前缀互含即拒(归一 posix 后 Python 端比对);同 working_dir 允许。
- **files API 是目录树唯一 mutation 入口,DB-FS 一致性服务端内化**:顶层目录 rename/delete 走 DB-aware 分支(事务锁关联 task、running→409、DB UPDATE 先于 FS、delete 被引用→409) - **DB-aware service 是顶层 working_dir mutation 的唯一原语,DB-FS 一致性服务端内化**:文件面板 rename 与对话 `rename_working_dir` 共用同一服务(事务锁关联 task、running→409、DB UPDATE 先于 FS);对话工具只登记本轮内存动作,等 agent 正常回复完、当前 task 退出 running 后执行,避免 executor/system prompt 仍握旧 cwd。服务在收尾前退出时动作丢失但目录不变不引持久队列或 migration。delete 仍仅走 files API被 task 引用时 409
- **单一 PG ORM**(本地 + SaaS 共用):一份 schema 一份查询,无 adapter,alembic 管 migration。 - **单一 PG ORM**(本地 + SaaS 共用):一份 schema 一份查询,无 adapter,alembic 管 migration。
### 7.5 沙盒:Per-user 容器 + Per-tool exec ### 7.5 沙盒:Per-user 容器 + Per-tool exec

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-07-27(Seedance 图生视频:单图首帧 + 最多 9 张多图参考,bump 0.59.5) 最后更新:2026-07-27(对话内安全重命名 working_dir,bump 0.59.6)
--- ---
@ -23,6 +23,7 @@
### 2026-07 ### 2026-07
- **07-27 / 0.59.6 / 对话内安全重命名 working_dir**:新增 `rename_working_dir` 受控工具agent run 内只登记目标 leaf 名,正常回复结束并把当前 task 退出 running 后再落地,避免 executor/system prompt/宿主工具仍握旧 cwd取消或失败不执行。文件面板与对话路径共用 `core/working_dirs.py` DB-aware 原语:锁定并同步更新共享目录的全部 task、活跃邻居与 no-subtask 冲突安全拒绝、DB UPDATE 后再做 FS rename。system prompt 明确当前 task_dir 禁走 shell/run_python 直接改名,定时 run 不挂该工具;无新增 schema/migration。全量 357 测试通过17 项按环境跳过DB 路由测试未设 `ZCBOT_TEST_DB_URL` 时按安全门控跳过。
- **07-27 / 0.59.5 / Seedance 图生视频(单图首帧 + 最多 9 张多图参考)**:`seedance` 在原文生视频契约上向后兼容增加 `image`(唯一 `first_frame`)与 `reference_images`(0-9 张 `reference_image`),两者合计最多 9 张;复用 image_ref 的 task/user_root 三形态路径解析、越界防护与 base64 内联,新增单图 10MB / 合计 30MB 请求体闸。请求按有无图片切图生/文生单价banner/meta/usage snapshot 记录 mode、首帧及参考图videogen skill、工具系统提示、RUN 与能力清单同步。5 个离线单测覆盖单图、多图、文生兼容、越界与超量拒绝。 - **07-27 / 0.59.5 / Seedance 图生视频(单图首帧 + 最多 9 张多图参考)**:`seedance` 在原文生视频契约上向后兼容增加 `image`(唯一 `first_frame`)与 `reference_images`(0-9 张 `reference_image`),两者合计最多 9 张;复用 image_ref 的 task/user_root 三形态路径解析、越界防护与 base64 内联,新增单图 10MB / 合计 30MB 请求体闸。请求按有无图片切图生/文生单价banner/meta/usage snapshot 记录 mode、首帧及参考图videogen skill、工具系统提示、RUN 与能力清单同步。5 个离线单测覆盖单图、多图、文生兼容、越界与超量拒绝。
- **07-24 / 0.59.4 / kb 冷启动契约(修「放进知识库」误存记忆)+ 查看原件 + 条目精简**:①真实用户事故——说「放进我的知识库」被 agent 写进 `.memory/`:根因 `kb_block` 零库返回空串,模型不知道 KB 机制存在(memory 空契约常驻恰是为解冷启动,kb 当时没照抄这课)。修 = 零库注 ~百 token 冷启动契约(建库步骤 / INDEX 行格式 / 「成篇资料进 KB、短事实进记忆」分工;仅用户明确要求才建库),建库落盘后下轮 build_agent 自然切全量注入;skill 方案再否(召回同为概率召回还多一层间接,且违背「机制而非 skill」定稿)。DESIGN §3.8 取舍随 commit 更新,`tests/test_kb_block.py` 4 用例锁两分支。②前端「查看原件」三入口(已入库条目行 👁 / 单篇页头 / 待入库列表),复用 `openFilePreview``.kb/<库>/sources/` 相对路径零后端;关键层级修正:file-preview-modal z 90→114 盖过 kb/skills/memory 等 112 层 modal(仍低于 app-dialog 130,删除确认在预览上),fp-open chat-form 95→115、mini 96→116,main.js Esc 关栈顺序同步先关预览再关 modal。「像文件目录浏览 .kb」提议对齐后定维持现状:.kb 继续对文件面板隐藏,守住删单篇「doc+原件+INDEX 行」三件联动判据。③已入库条目压两行:标题 + 单行省略摘要(原 `.sk-desc` 省略规则只覆盖左栏 `.sk-item`,右栏整段换行铺开),关键词挪进悬停 title 不丢信息。 - **07-24 / 0.59.4 / kb 冷启动契约(修「放进知识库」误存记忆)+ 查看原件 + 条目精简**:①真实用户事故——说「放进我的知识库」被 agent 写进 `.memory/`:根因 `kb_block` 零库返回空串,模型不知道 KB 机制存在(memory 空契约常驻恰是为解冷启动,kb 当时没照抄这课)。修 = 零库注 ~百 token 冷启动契约(建库步骤 / INDEX 行格式 / 「成篇资料进 KB、短事实进记忆」分工;仅用户明确要求才建库),建库落盘后下轮 build_agent 自然切全量注入;skill 方案再否(召回同为概率召回还多一层间接,且违背「机制而非 skill」定稿)。DESIGN §3.8 取舍随 commit 更新,`tests/test_kb_block.py` 4 用例锁两分支。②前端「查看原件」三入口(已入库条目行 👁 / 单篇页头 / 待入库列表),复用 `openFilePreview``.kb/<库>/sources/` 相对路径零后端;关键层级修正:file-preview-modal z 90→114 盖过 kb/skills/memory 等 112 层 modal(仍低于 app-dialog 130,删除确认在预览上),fp-open chat-form 95→115、mini 96→116,main.js Esc 关栈顺序同步先关预览再关 modal。「像文件目录浏览 .kb」提议对齐后定维持现状:.kb 继续对文件面板隐藏,守住删单篇「doc+原件+INDEX 行」三件联动判据。③已入库条目压两行:标题 + 单行省略摘要(原 `.sk-desc` 省略规则只覆盖左栏 `.sk-item`,右栏整段换行铺开),关键词挪进悬停 title 不丢信息。
- **07-23 / 0.59.3 / 知识库下载入口(原件 + 解析 Markdown,纯前端)**:kb 面板此前只能在线看不能取回。关键发现:`.kb/` 就在 user_root 下且 `/v1/files/download` 的 safe_join 只挡越界不挡 dotfile → 零后端改动,`kb.js` 复用 `media.js::downloadFile`(Bearer + blob)拼 `.kb/<库>/{sources,docs}/…` 路径即可。入口三处:已入库条目行「⬇ 下载原件」(agent 手写无原件的条目不显示)、单篇查看页头「下载原件 / 下载 Markdown」(doc→source 映射渲染详情时记 `_docSources`)、待入库列表逐项「下载」。弃选方案:新增 `/v1/kb/*/sources` 专属下载端点(与 files 下载全量重复,公测期白养一个对外契约)。改动仅 kb.js + dev.html 三行 CSS。 - **07-23 / 0.59.3 / 知识库下载入口(原件 + 解析 Markdown,纯前端)**:kb 面板此前只能在线看不能取回。关键发现:`.kb/` 就在 user_root 下且 `/v1/files/download` 的 safe_join 只挡越界不挡 dotfile → 零后端改动,`kb.js` 复用 `media.js::downloadFile`(Bearer + blob)拼 `.kb/<库>/{sources,docs}/…` 路径即可。入口三处:已入库条目行「⬇ 下载原件」(agent 手写无原件的条目不显示)、单篇查看页头「下载原件 / 下载 Markdown」(doc→source 映射渲染详情时记 `_docSources`)、待入库列表逐项「下载」。弃选方案:新增 `/v1/kb/*/sources` 专属下载端点(与 files 下载全量重复,公测期白养一个对外契约)。改动仅 kb.js + dev.html 三行 CSS。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.59.5" __version__ = "0.59.6"

View File

@ -40,6 +40,7 @@ from core.sinks import ConsoleEventSink
from core.skills import SkillRegistry from core.skills import SkillRegistry
from core.storage import check_no_subtask from core.storage import check_no_subtask
from core.task import TaskState from core.task import TaskState
from core.task_actions import DeferredTaskActions
from core.ark_client import ArkConfig from core.ark_client import ArkConfig
from core.tool_registry import ToolContext, build_tools from core.tool_registry import ToolContext, build_tools
@ -308,6 +309,7 @@ def _build_system_prompt(
task_name: str, task_name: str,
task_skill: str = "", task_skill: str = "",
media_block: str = "", media_block: str = "",
allow_working_dir_rename: bool = True,
) -> str: ) -> str:
"""拼 system prompt: 模板 + skill 列表 + memory + 工作目录段 + task 上下文 + 命名约定。 """拼 system prompt: 模板 + skill 列表 + memory + 工作目录段 + task 上下文 + 命名约定。
@ -379,6 +381,12 @@ def _build_system_prompt(
f"- cwd(用户启动时所在目录,只读用): `{tool_base}`\n" f"- cwd(用户启动时所在目录,只读用): `{tool_base}`\n"
f"- **task_dir(所有产物写到这里)**: `{wd_path}`\n" f"- **task_dir(所有产物写到这里)**: `{wd_path}`\n"
) )
rename_line = (
"用户要求修改当前 task_dir 名称时,必须调用 `rename_working_dir`"
"不要用 shell / run_python 直接移动或改名当前 task_dir。该工具会在本轮回复"
"完成后执行,所以调用后按“已登记、将在回复后完成”表述。\n"
if allow_working_dir_rename else ""
)
prompt += ( prompt += (
f"\n\n## 工作目录与 task 上下文\n" f"\n\n## 工作目录与 task 上下文\n"
f"{loc_lines}" f"{loc_lines}"
@ -391,6 +399,7 @@ def _build_system_prompt(
f"普通产物(sections / slides / 终稿 .docx/.pptx)按 SKILL 文档落路径;" f"普通产物(sections / slides / 终稿 .docx/.pptx)按 SKILL 文档落路径;"
f"「宪法」性文件(spec 等)按下面《task 级「宪法」文件命名约定》拼路径。\n" f"「宪法」性文件(spec 等)按下面《task 级「宪法」文件命名约定》拼路径。\n"
f"⛔ 不要把产物写到 cwd / `skills/` / repo 根 —— 只写到 task_dir。\n" f"⛔ 不要把产物写到 cwd / `skills/` / repo 根 —— 只写到 task_dir。\n"
f"{rename_line}"
f"\n## 生成 Word 报告 / 文档(验收 / 技术 / 评审报告等自由长文)\n" f"\n## 生成 Word 报告 / 文档(验收 / 技术 / 评审报告等自由长文)\n"
f"**优先**把正文写成 Markdown(`<task_dir>/sections/*.md`,纯文本、零转义 / 零语法风险)," f"**优先**把正文写成 Markdown(`<task_dir>/sections/*.md`,纯文本、零转义 / 零语法风险),"
f"再调平台渲染器出 docx —— **别在 run_python 里手撸 python-docx 内联大段中文正文**" f"再调平台渲染器出 docx —— **别在 run_python 里手撸 python-docx 内联大段中文正文**"
@ -540,6 +549,7 @@ def build_agent(
cfg, skills, workspace_dir, tool_base, working_dir_path, uid, cfg, skills, workspace_dir, tool_base, working_dir_path, uid,
task_id, task_state.name, task_state.skill, task_id, task_state.name, task_state.skill,
media_block=_media_tools_block(ark_cfg is not None, image_tool_name), media_block=_media_tools_block(ark_cfg is not None, image_tool_name),
allow_working_dir_rename=not scheduled_run,
) )
meta = { meta = {
@ -563,11 +573,13 @@ def build_agent(
# user_root 传给 tool 让 fs 输出渲染成相对路径(不泄漏 user_id / 部署根, # user_root 传给 tool 让 fs 输出渲染成相对路径(不泄漏 user_id / 部署根,
# 同时让 web SPA artifact chip 抽取稳定锚定 <wd>/ 前缀) # 同时让 web SPA artifact chip 抽取稳定锚定 <wd>/ 前缀)
ur_path = user_root(workspace_dir, uid) ur_path = user_root(workspace_dir, uid)
deferred_actions = DeferredTaskActions()
tools = build_tools(ToolContext( tools = build_tools(ToolContext(
tool_base=tool_base, ur_path=ur_path, working_dir_path=working_dir_path, tool_base=tool_base, ur_path=ur_path, working_dir_path=working_dir_path,
task_id=task_id, uid=uid, cfg=cfg, caps=caps, skills=skills, task_id=task_id, uid=uid, cfg=cfg, caps=caps, skills=skills,
cancel_check=cancel_check, scheduled_run=scheduled_run, cancel_check=cancel_check, scheduled_run=scheduled_run,
deferred_actions=deferred_actions,
ark_cfg=ark_cfg, img_provider=img_provider, img_key=img_key, ark_cfg=ark_cfg, img_provider=img_provider, img_key=img_key,
img_cfg=img_cfg, img_provider_cfg=img_provider_cfg, img_cfg=img_cfg, img_provider_cfg=img_provider_cfg,
video_variant=video_variant, video_variant=video_variant,
@ -618,6 +630,7 @@ def build_agent(
llm, executor, session, caps, llm, executor, session, caps,
user_id=uid, working_dir=working_dir_path, sink=sink, user_id=uid, working_dir=working_dir_path, sink=sink,
skill_model_switch=_skill_model_switch, skill_model_switch=_skill_model_switch,
deferred_actions=deferred_actions,
) )
if cancel_check is not None: if cancel_check is not None:
agent.cancel_check = cancel_check agent.cancel_check = cancel_check

View File

@ -40,6 +40,7 @@ from .llm_transport import (
) )
from .salvage import salvage_tool_arguments from .salvage import salvage_tool_arguments
from .session import Session from .session import Session
from .task_actions import DeferredTaskActions
from .storage import ( from .storage import (
record_chat_usage, record_chat_usage,
record_salvaged_tool_call, record_salvaged_tool_call,
@ -196,6 +197,7 @@ class AgentLoop:
max_iterations: Optional[int] = None, max_iterations: Optional[int] = None,
cancel_check: Optional[Callable[[], bool]] = None, cancel_check: Optional[Callable[[], bool]] = None,
skill_model_switch: Optional[Callable[[str, str], Optional[Tuple[str, Any, Any]]]] = None, skill_model_switch: Optional[Callable[[str, str], Optional[Tuple[str, Any, Any]]]] = None,
deferred_actions: Optional[DeferredTaskActions] = None,
) -> None: ) -> None:
self.llm = llm self.llm = llm
self.executor = executor self.executor = executor
@ -216,6 +218,9 @@ class AgentLoop:
# 调它,返回 (新 profile, caps, llm) 则热切 —— 本 run 内下一次 LLM 调用即生效。 # 调它,返回 (新 profile, caps, llm) 则热切 —— 本 run 内下一次 LLM 调用即生效。
# None(CLI 旧调用方 / 测试)= 不启用。loop 不碰 DB/config,切换判定与持久化全在闭包里。 # None(CLI 旧调用方 / 测试)= 不启用。loop 不碰 DB/config,切换判定与持久化全在闭包里。
self.skill_model_switch = skill_model_switch self.skill_model_switch = skill_model_switch
# rename_working_dir 等不能在 executor 仍握旧 cwd 时落地的动作,由 web worker
# 在 agent.run 正常结束后消费。CLI/旧测试不传时保留空容器。
self.deferred_actions = deferred_actions or DeferredTaskActions()
# 病理性重复调用守卫(同名同参 + 无产出),活在本次 run 内,不跨 task。 # 病理性重复调用守卫(同名同参 + 无产出),活在本次 run 内,不跨 task。
self._repeat_guard = _RepeatGuard() self._repeat_guard = _RepeatGuard()
# 全局「无进展」计数:连续多少步整步无净产出。有净产出清零,见 run loop 熔断。 # 全局「无进展」计数:连续多少步整步无净产出。有净产出清零,见 run loop 熔断。

19
core/task_actions.py Normal file
View File

@ -0,0 +1,19 @@
"""一次 agent run 结束后执行的轻量 task 动作。
这类动作不能在工具调用当下执行例如重命名当前 working_dir 会让本轮 executor
缓存的 cwdsystem prompt 路径和宿主工具 base_dir 同时失效工具只在这里登记
意图 web run worker agent.run 正常结束run_status 退出 running 后落地
动作只活在当前进程内不是持久任务队列服务在 run 收尾前退出时请求自然丢失
但不会留下 DB / FS 半更新状态用户下次可重新发起
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class DeferredTaskActions:
"""当前 run 已登记、等待 worker 收尾执行的动作。"""
rename_working_dir_to: str = ""

View File

@ -34,6 +34,7 @@ from tools.materials_project import (
MaterialsProjectSearchSummaryTool, MaterialsProjectSearchSummaryTool,
) )
from tools.read_document import ReadDocumentTool from tools.read_document import ReadDocumentTool
from tools.rename_working_dir import RenameWorkingDirTool
from tools.run_python import RunPythonTool from tools.run_python import RunPythonTool
from tools.schedule import ( from tools.schedule import (
ScheduleCancelTool, ScheduleCreateTool, ScheduleListTool, ScheduleUpdateTool, ScheduleCancelTool, ScheduleCreateTool, ScheduleListTool, ScheduleUpdateTool,
@ -52,6 +53,7 @@ from tools.wechat_bot import WechatPushTool, wechat_push_available
from core.asr_lfasr import is_configured as lfasr_configured from core.asr_lfasr import is_configured as lfasr_configured
from core.bocha_client import BochaConfig from core.bocha_client import BochaConfig
from core.task_actions import DeferredTaskActions
@dataclass @dataclass
@ -67,6 +69,7 @@ class ToolContext:
skills: Any # SkillRegistry skills: Any # SkillRegistry
cancel_check: Optional[Callable[[], bool]] cancel_check: Optional[Callable[[], bool]]
scheduled_run: bool scheduled_run: bool
deferred_actions: DeferredTaskActions
# 媒体(上游已 load 一次,避免重复读 yaml): # 媒体(上游已 load 一次,避免重复读 yaml):
ark_cfg: Any # ArkConfig | None(豆包;None=ARK_API_KEY 缺) ark_cfg: Any # ArkConfig | None(豆包;None=ARK_API_KEY 缺)
img_provider: str # "doubao" / "unifyllm" / "" img_provider: str # "doubao" / "unifyllm" / ""
@ -115,6 +118,15 @@ def build_tools(ctx: ToolContext) -> dict[str, Any]:
WebFetchTool(**base), WebFetchTool(**base),
] ]
def _task_actions() -> list:
return [
RenameWorkingDirTool(
ctx.deferred_actions,
working_dir=ctx.working_dir_path,
**base,
)
]
def _document_search() -> list: def _document_search() -> list:
return [ return [
DocumentListKbTool(**base), DocumentListKbTool(**base),
@ -216,6 +228,8 @@ def build_tools(ctx: ToolContext) -> dict[str, Any]:
# ── 注册表:(组名, gate, factory)。gate 判定统一零参 bool;新工具在此加行 ── # ── 注册表:(组名, gate, factory)。gate 判定统一零参 bool;新工具在此加行 ──
registry: list[tuple[str, Callable[[], bool], Callable[[], list]]] = [ registry: list[tuple[str, Callable[[], bool], Callable[[], list]]] = [
("core", lambda: True, _core), ("core", lambda: True, _core),
# 当前 working_dir 只能延迟到交互 run 收尾后改名;定时 run 不允许自行改目录。
("task_actions", lambda: not ctx.scheduled_run, _task_actions),
# Secret-bearing 域工具一律 host-side、仅对应 env 存在才注册(§7.5 #7): # Secret-bearing 域工具一律 host-side、仅对应 env 存在才注册(§7.5 #7):
# key 绝不进 run_python / 沙箱。 # key 绝不进 run_python / 沙箱。
("document_search", _env_set("DOCUMENT_SEARCH_API_KEY"), _document_search), ("document_search", _env_set("DOCUMENT_SEARCH_API_KEY"), _document_search),

106
core/working_dirs.py Normal file
View File

@ -0,0 +1,106 @@
"""working_dir 的 DB-aware 文件系统变更。
网页 files API 与对话内延迟动作共用这里保证顶层工作目录改名始终同时更新所有
关联 task且不会与活跃 run no-subtask 约束打架
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from uuid import UUID
from sqlalchemy import select, update
from .paths import to_db_path
from .storage import NoSubtaskError, check_no_subtask, session_scope
from .storage.models import Task
class WorkingDirRenameError(RuntimeError):
"""working_dir 无法安全改名。"""
class WorkingDirConflictError(WorkingDirRenameError):
"""目录状态或 task 约束冲突,可由用户调整后重试。"""
class WorkingDirBusyError(WorkingDirConflictError):
"""至少一个关联 task 仍有活跃 run。"""
def __init__(self, task_ids: list[str]) -> None:
self.task_ids = task_ids
super().__init__(
f"folder has active run(s) on task(s) {task_ids}; cancel before renaming"
)
@dataclass(frozen=True)
class WorkingDirRenameResult:
old_path: Path
new_path: Path
tasks_updated: int
def rename_working_dir(
*,
user_id: UUID,
old_path: Path,
new_path: Path,
) -> WorkingDirRenameResult:
"""原子地平移一个顶层 working_dir 的 DB 引用并重命名目录。
调用方负责用户边界与 leaf 名校验本函数仍校验同级目录/目标状态和并发
task作为网页路由与延迟动作共用的最后一道一致性闸
顺序为 DB UPDATE FS rename transaction commitFS 失败会回滚 DB仍存在
极小的FS 已成功但 PG commit 失败窗口与原 files API 的既有语义一致
"""
old = Path(old_path).resolve()
new = Path(new_path).resolve()
if old.parent != new.parent:
raise WorkingDirRenameError("working_dir rename must stay under the same parent")
if old == new:
raise WorkingDirRenameError("new working_dir equals the old path")
if not old.exists():
raise WorkingDirRenameError(f"working_dir not found: {old}")
if not old.is_dir():
raise WorkingDirRenameError(f"working_dir is not a directory: {old}")
if new.exists():
raise WorkingDirConflictError(f"target already exists: {new}")
old_db = to_db_path(old)
new_db = to_db_path(new)
with session_scope() as s:
rows = s.execute(
select(Task.task_id, Task.run_status)
.where(Task.user_id == user_id, Task.working_dir == old_db)
.with_for_update()
).all()
tids = [r.task_id for r in rows]
active = [
str(r.task_id)[:8]
for r in rows
if r.run_status in ("running", "cancelling")
]
if active:
raise WorkingDirBusyError(active)
try:
check_no_subtask(new_db, user_id=user_id, exclude_task_ids=tids)
except NoSubtaskError as e:
raise WorkingDirConflictError(str(e)) from e
if tids:
s.execute(
update(Task)
.where(Task.task_id.in_(tids))
.values(working_dir=new_db)
)
try:
old.rename(new)
except OSError as e:
raise WorkingDirRenameError(f"FS rename failed: {e}") from e
return WorkingDirRenameResult(
old_path=old,
new_path=new,
tasks_updated=len(tids),
)

View File

@ -0,0 +1,214 @@
"""对话内 working_dir 延迟改名:工具登记 + worker 收尾时序。"""
from __future__ import annotations
import tempfile
import unittest
from contextlib import contextmanager
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from uuid import uuid4
from core.task_actions import DeferredTaskActions
from core.working_dirs import (
WorkingDirBusyError,
WorkingDirRenameResult,
rename_working_dir,
)
from tools.rename_working_dir import RenameWorkingDirTool
class RenameWorkingDirToolTests(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
self.wd = self.root / "旧目录"
self.wd.mkdir()
self.actions = DeferredTaskActions()
self.tool = RenameWorkingDirTool(
self.actions,
working_dir=self.wd,
base_dir=self.wd,
user_root=self.root,
)
def tearDown(self) -> None:
self.tmp.cleanup()
def test_registers_and_replaces_deferred_name(self) -> None:
out = self.tool.execute(" 新目录 ")
self.assertIn("[OK]", out)
self.assertEqual(self.actions.rename_working_dir_to, "新目录")
out = self.tool.execute("最终目录")
self.assertIn("更新为", out)
self.assertEqual(self.actions.rename_working_dir_to, "最终目录")
self.assertTrue(self.wd.is_dir(), "工具调用阶段不应立即改动文件系统")
def test_rejects_invalid_same_or_existing_name(self) -> None:
for bad in ("", ".memory", "a/b", "a\\b", "旧目录"):
with self.subTest(name=bad):
self.actions.rename_working_dir_to = ""
self.assertIn("[Error]", self.tool.execute(bad))
self.assertEqual(self.actions.rename_working_dir_to, "")
(self.root / "已存在").mkdir()
self.assertIn("[Error]", self.tool.execute("已存在"))
self.assertEqual(self.actions.rename_working_dir_to, "")
class DeferredRenameWorkerTests(unittest.TestCase):
def test_normal_run_renames_after_status_commit_before_done(self) -> None:
from web import runs
tid = uuid4()
uid = uuid4()
with tempfile.TemporaryDirectory() as tmp:
old = Path(tmp) / "旧目录"
old.mkdir()
new = old.parent / "新目录"
actions = DeferredTaskActions(rename_working_dir_to="新目录")
agent = SimpleNamespace(
run=MagicMock(return_value="ok"),
deferred_actions=actions,
sink=None,
)
order: list[str] = []
@contextmanager
def fake_scope():
yield SimpleNamespace(execute=MagicMock())
order.append("status_committed")
broker = MagicMock()
def fake_rename(**kwargs):
order.append("renamed")
self.assertEqual(kwargs["old_path"], old)
self.assertEqual(kwargs["new_path"], new)
return WorkingDirRenameResult(old, new, 1)
with (
patch("core.agent_builder.build_agent", return_value=(
agent, MagicMock(), str(tid), MagicMock(), old,
)),
patch("core.agent_builder.sync_task_tokens"),
patch.object(runs, "session_scope", fake_scope),
patch.object(runs, "broker", broker),
patch("core.working_dirs.rename_working_dir", side_effect=fake_rename),
):
runs.run_agent_bg(tid, uid, "把目录改名")
self.assertEqual(order, ["status_committed", "renamed"])
broker.clear_cancel.assert_called_once_with(tid)
broker.close.assert_called_once_with(tid)
broker.emit.assert_any_call(
tid,
{
"type": "warn",
"level": "info",
"msg": "工作目录已重命名为 新目录",
},
)
def test_cancelled_run_does_not_apply_deferred_rename(self) -> None:
from web import runs
tid = uuid4()
uid = uuid4()
actions = DeferredTaskActions(rename_working_dir_to="新目录")
agent = SimpleNamespace(
run=MagicMock(return_value="[cancelled]"),
deferred_actions=actions,
sink=None,
)
@contextmanager
def fake_scope():
yield SimpleNamespace(execute=MagicMock())
with tempfile.TemporaryDirectory() as tmp:
old = Path(tmp) / "旧目录"
old.mkdir()
with (
patch("core.agent_builder.build_agent", return_value=(
agent, MagicMock(), str(tid), MagicMock(), old,
)),
patch("core.agent_builder.sync_task_tokens"),
patch.object(runs, "session_scope", fake_scope),
patch.object(runs, "broker", MagicMock()),
patch("core.working_dirs.rename_working_dir") as rename,
):
runs.run_agent_bg(tid, uid, "停止")
rename.assert_not_called()
class WorkingDirServiceTests(unittest.TestCase):
def test_updates_all_associated_tasks_and_renames_fs(self) -> None:
uid = uuid4()
tids = [uuid4(), uuid4()]
rows = [
SimpleNamespace(task_id=tid, run_status="idle")
for tid in tids
]
session = MagicMock()
session.execute.side_effect = [
SimpleNamespace(all=lambda: rows),
MagicMock(),
]
@contextmanager
def fake_scope():
yield session
with tempfile.TemporaryDirectory() as tmp:
old = Path(tmp) / "旧目录"
new = Path(tmp) / "新目录"
old.mkdir()
(old / "产物.txt").write_text("ok", encoding="utf-8")
with (
patch("core.working_dirs.session_scope", fake_scope),
patch("core.working_dirs.check_no_subtask"),
patch("core.working_dirs.to_db_path", side_effect=["old-db", "new-db"]),
):
result = rename_working_dir(
user_id=uid,
old_path=old,
new_path=new,
)
self.assertEqual(result.tasks_updated, 2)
self.assertFalse(old.exists())
self.assertEqual((new / "产物.txt").read_text(encoding="utf-8"), "ok")
self.assertEqual(session.execute.call_count, 2)
def test_active_associated_task_blocks_before_fs_change(self) -> None:
uid = uuid4()
rows = [SimpleNamespace(task_id=uuid4(), run_status="running")]
session = MagicMock()
session.execute.return_value = SimpleNamespace(all=lambda: rows)
@contextmanager
def fake_scope():
yield session
with tempfile.TemporaryDirectory() as tmp:
old = Path(tmp) / "旧目录"
new = Path(tmp) / "新目录"
old.mkdir()
with (
patch("core.working_dirs.session_scope", fake_scope),
patch("core.working_dirs.to_db_path", side_effect=["old-db", "new-db"]),
):
with self.assertRaises(WorkingDirBusyError):
rename_working_dir(
user_id=uid,
old_path=old,
new_path=new,
)
self.assertTrue(old.is_dir())
self.assertFalse(new.exists())
if __name__ == "__main__":
unittest.main()

View File

@ -26,7 +26,7 @@ _TID = UUID("ab063233-7e90-4f57-9afe-3f7e47b13522")
_HOST_CWD = Path("/home/ubuntu/zcbot") # 模拟 prod 宿主启动目录 _HOST_CWD = Path("/home/ubuntu/zcbot") # 模拟 prod 宿主启动目录
def _build(backend: str) -> tuple[str, Path, Path]: def _build(backend: str, *, allow_rename: bool = True) -> tuple[str, Path, Path]:
cfg = load_config() cfg = load_config()
skills = SkillRegistry(ROOT / cfg.get("skills_dir", "skills")) skills = SkillRegistry(ROOT / cfg.get("skills_dir", "skills"))
tmp = tempfile.mkdtemp() tmp = tempfile.mkdtemp()
@ -35,7 +35,8 @@ def _build(backend: str) -> tuple[str, Path, Path]:
wd.mkdir(parents=True, exist_ok=True) wd.mkdir(parents=True, exist_ok=True)
with patch.dict("os.environ", {"ZCBOT_SANDBOX_BACKEND": backend}, clear=False): with patch.dict("os.environ", {"ZCBOT_SANDBOX_BACKEND": backend}, clear=False):
prompt = _build_system_prompt( prompt = _build_system_prompt(
cfg, skills, ws, _HOST_CWD, wd, _UID, _TID, "数据资源展示", "documents" cfg, skills, ws, _HOST_CWD, wd, _UID, _TID, "数据资源展示", "documents",
allow_working_dir_rename=allow_rename,
) )
return prompt, ws, wd return prompt, ws, wd
@ -59,6 +60,12 @@ class TestSystemPromptPaths(unittest.TestCase):
self.assertIn(str(wd), prompt) self.assertIn(str(wd), prompt)
self.assertNotIn("/workspace/数据资源展示", prompt) self.assertNotIn("/workspace/数据资源展示", prompt)
def test_rename_guard_only_when_tool_is_available(self):
interactive, _, _ = _build("docker", allow_rename=True)
scheduled, _, _ = _build("docker", allow_rename=False)
self.assertIn("必须调用 `rename_working_dir`", interactive)
self.assertNotIn("必须调用 `rename_working_dir`", scheduled)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -0,0 +1,65 @@
"""对话内重命名当前 task 工作目录登记意图run 收尾后执行。"""
from __future__ import annotations
from pathlib import Path
from core.task_actions import DeferredTaskActions
from .base import Tool
class RenameWorkingDirTool(Tool):
name = "rename_working_dir"
description = (
"Rename the current task's top-level working directory. "
"The rename is safely deferred until the current response finishes, so all tasks "
"sharing this directory keep their association. Use this instead of shell mv/rename "
"for the current working directory."
)
parameters = {
"type": "object",
"properties": {
"new_name": {
"type": "string",
"description": "新的目录 leaf 名,不是路径;不能含 / 或 \\,不能以 . 开头。",
},
},
"required": ["new_name"],
}
def __init__(
self,
actions: DeferredTaskActions,
working_dir: Path,
**kwargs,
) -> None:
super().__init__(**kwargs)
self.actions = actions
self.working_dir = Path(working_dir)
def execute(self, new_name: str) -> str:
# 延迟 import避免 agent_builder -> tool_registry -> 本工具 -> agent_builder
# 的模块初始化环;名称规则仍只有 validate_task_name 一个事实源。
from core.agent_builder import InvalidTaskName, validate_task_name
try:
safe = validate_task_name(new_name)
except InvalidTaskName as e:
return f"[Error] new_name 不合法: {e}"
if safe == self.working_dir.name:
return f"[Error] new_name 与当前工作目录名相同: {safe!r}"
target = self.working_dir.parent / safe
if target.exists():
return f"[Error] 目标目录已存在: {safe!r}"
replaced = self.actions.rename_working_dir_to
self.actions.rename_working_dir_to = safe
if replaced and replaced != safe:
return (
f"[OK] 已把本轮待执行的工作目录改名从 {replaced!r} 更新为 {safe!r}"
"将在本轮回复完成后安全执行。"
)
return (
f"[OK] 已登记工作目录改名为 {safe!r},将在本轮回复完成后安全执行;"
"所有共享该目录的 task 关联会同步更新。"
)

View File

@ -1,7 +1,7 @@
"""Files 路由(user-rooted,不绑 task)+ 用户磁盘用量 + pptx 在线预览。 """Files 路由(user-rooted,不绑 task)+ 用户磁盘用量 + pptx 在线预览。
目录树唯一 mutation 入口(DESIGN §7.4):顶层目录 rename/delete/move DB-aware 文件面板的目录树 mutation 入口(DESIGN §7.4):顶层目录 rename 复用
分支(事务锁关联 taskrunning409DB UPDATE 先于 FS)路径安全原语在 core.working_dirs DB-aware 服务delete/move 走本路由状态闸路径安全原语在
web/userfiles.py web/userfiles.py
""" """
from __future__ import annotations from __future__ import annotations
@ -12,11 +12,16 @@ from uuid import UUID
from fastapi import Depends, File, Form, HTTPException, UploadFile from fastapi import Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy import func, select, update from sqlalchemy import func, select
from core.paths import to_db_path from core.paths import to_db_path
from core.storage import NoSubtaskError, check_no_subtask, session_scope from core.storage import session_scope
from core.storage.models import Task from core.storage.models import Task
from core.working_dirs import (
WorkingDirConflictError,
WorkingDirRenameError,
rename_working_dir,
)
from ..common import norm_path from ..common import norm_path
from ..schemas import FileDeleteRequest, FileRenameRequest, FileTransferRequest from ..schemas import FileDeleteRequest, FileRenameRequest, FileTransferRequest
@ -319,47 +324,24 @@ def register_file_routes(app, *, require_user) -> None:
"tasks_updated": 0, "tasks_updated": 0,
} }
# 顶层目录:DB-aware # 顶层目录:网页和对话内延迟动作共用同一个 DB-aware 服务。
old_db = to_db_path(target)
new_db = to_db_path(new_target)
with session_scope() as s:
rows = s.execute(
select(Task.task_id, Task.run_status)
.where(Task.user_id == user_id, Task.working_dir == old_db)
.with_for_update()
).all()
tids = [r.task_id for r in rows]
active = [
str(r.task_id)[:8] for r in rows
if r.run_status in ("running", "cancelling")
]
if active:
raise HTTPException(
409,
f"folder has active run(s) on task(s) {active}; "
f"cancel before renaming",
)
try: try:
check_no_subtask(new_db, user_id=user_id, exclude_task_ids=tids) renamed = rename_working_dir(
except NoSubtaskError as e: user_id=user_id,
old_path=target,
new_path=new_target,
)
except WorkingDirConflictError as e:
raise HTTPException(409, str(e)) raise HTTPException(409, str(e))
if tids: except WorkingDirRenameError as e:
s.execute( # 保持原 files API 的普通 FS 失败 400 语义。
update(Task) raise HTTPException(400, str(e))
.where(Task.task_id.in_(tids))
.values(working_dir=new_db)
)
try:
target.rename(new_target)
except OSError as e:
# 抛 HTTPException 也会让 session_scope 走 except 分支回滚 UPDATE
raise HTTPException(400, f"FS rename failed: {e}")
return { return {
"ok": True, "ok": True,
"old": body.path, "old": body.path,
"new": rel_to(root, new_target), "new": rel_to(root, new_target),
"tasks_updated": len(tids), "tasks_updated": renamed.tasks_updated,
} }
@app.post("/v1/files/copy", tags=["files"]) @app.post("/v1/files/copy", tags=["files"])

View File

@ -66,6 +66,38 @@ def run_agent_bg(
run_status=final_status, run_error=None, run_status=final_status, run_error=None,
) )
) )
# 当前 working_dir 不能在 agent 仍执行工具时改名executor/system prompt 都握
# 旧 cwd。rename_working_dir 工具只登记意图;正常完成并退出 running 后,
# 在 done 事件前统一走 DB-aware 服务。取消时不执行,避免用户停止后仍发生变更。
rename_to = agent.deferred_actions.rename_working_dir_to
if final_status == "idle" and rename_to:
from core.working_dirs import WorkingDirRenameError, rename_working_dir
try:
renamed = rename_working_dir(
user_id=user_id,
old_path=task_dir,
new_path=task_dir.parent / rename_to,
)
broker.emit(task_id, {
"type": "warn",
"level": "info",
"msg": f"工作目录已重命名为 {renamed.new_path.name}",
})
except WorkingDirRenameError as e:
broker.emit(task_id, {
"type": "warn",
"msg": f"工作目录改名未执行:{e}",
})
except Exception as e:
# 延迟附加动作失败不应把已经成功的对话 run 改标 error保留目录原状
# 给当前订阅者明确提示,下轮可重试。
broker.emit(task_id, {
"type": "warn",
"msg": (
"工作目录改名未执行:"
f"{type(e).__name__}: {e}"
),
})
except Exception as e: except Exception as e:
err = f"{type(e).__name__}: {e}" err = f"{type(e).__name__}: {e}"
broker.emit(task_id, {"type": "error", "msg": err}) broker.emit(task_id, {"type": "error", "msg": err})

View File

@ -33,6 +33,7 @@ export function toolActivityLabel(name, args) {
case "web_fetch": return `抓取网页: ${clip(a.url, 80)}`; case "web_fetch": return `抓取网页: ${clip(a.url, 80)}`;
case "web_search": return `联网搜索: ${clip(a.query, 60)}`; case "web_search": return `联网搜索: ${clip(a.query, 60)}`;
case "load_skill": return `加载技能: ${clip(a.name, 40)}`; case "load_skill": return `加载技能: ${clip(a.name, 40)}`;
case "rename_working_dir": return `重命名工作目录: ${clip(a.new_name, 60)}`;
case "seedream": return `生成图像: ${clip(a.prompt, 60)}`; case "seedream": return `生成图像: ${clip(a.prompt, 60)}`;
case "gpt_image": return `生成图像: ${clip(a.prompt, 60)}`; case "gpt_image": return `生成图像: ${clip(a.prompt, 60)}`;
case "seedance": return `生成视频: ${clip(a.prompt, 60)}`; case "seedance": return `生成视频: ${clip(a.prompt, 60)}`;